diff --git a/.eslintrc.json b/.eslintrc.json index bc144bb6..9625fb75 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,62 +1,52 @@ { - "extends": [ - "eslint:recommended" - ], - "parserOptions": { - "ecmaVersion": 2022, - "sourceType": "module" - }, - "env": { - "browser": true, - "node": true, - "es2022": true - }, - "globals": { - "App": true - }, - "ignorePatterns": [ - "dist/*", - "frontend/dist/*" - ], - "overrides": [ - { - "files": [ - "**/*.ts", - "**/*.tsx" - ], - "parser": "@typescript-eslint/parser", - "plugins": [ - "@typescript-eslint" - ], - "extends": [ - "eslint:recommended", - "plugin:react/recommended", - "plugin:@typescript-eslint/recommended", - "plugin:react/jsx-runtime", - "plugin:import/recommended", - "plugin:import/typescript" - ], - "parserOptions": { - "ecmaVersion": 2017, - "sourceType": "module" - }, - "env": { - "browser": true, - "node": true, - "es6": true - }, - "globals": { - "App": true - }, - "rules": { - "react/jsx-uses-react": "off", - "react/react-in-jsx-scope": "off" + "extends": ["eslint:recommended"], + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module" + }, + "env": { + "browser": true, + "node": true, + "es2022": true + }, + "globals": { + "App": true + }, + "ignorePatterns": ["**/dist/**"], + "overrides": [ + { + "files": ["**/*.ts", "**/*.tsx"], + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react/jsx-runtime", + "plugin:import/recommended", + "plugin:import/typescript" + ], + "parserOptions": { + "ecmaVersion": 2017, + "sourceType": "module" + }, + "env": { + "browser": true, + "node": true, + "es6": true + }, + "globals": { + "App": true + }, + "rules": { + "react/jsx-uses-react": "off", + "react/react-in-jsx-scope": "off" + } } - } - ], - "settings": { - "react": { - "version": "detect" - } - } -} \ No newline at end of file + ], + "settings": { + "react": { + "version": "detect" + } + } +} diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..21bc1c4d --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,359 @@ +# Pulldasher — Design System (frontend-v2) + +The visual system as implemented, plus the reasoning that produced it. Token +values live in `frontend-v2/src/styles.css` (`:root` light, `.dark`) — this +file records the *rules*; the CSS records the *numbers*. Most of these rules +were reached by iterating with the product owner against the live dummy board +(July 2026); the "why" notes are load-bearing — don't undo a rule without +rereading its why. + +## The one color rule + +**Color means "look here"; no color means "nothing to see."** A fully quiet +row — outlined marks, no bar, faint strip — is a healthy PR that needs +nothing. + +Hierarchy (one hue = one meaning, board-wide): + +| Color | Meaning | Notes | +|---|---|---| +| red `--bad` | broken (CI failure) | rare by design; never on healthy rows | +| amber `--warn` | you/someone owes something (lapsed stamp, aging, blocks) | the action color | +| green `--ok` | quiet confirmation (live stamp, CI green on hover) | tuned *calmest* of the three | +| brand blue | yours / interactive (your-move headers, links, fresh) | never decorative | +| violet `--violet` | QA as a *category* (status-filter dots, QA leaderboard) | chrome only — never on a row; rows encode QA via pips + position | +| slate `--slate` | in-progress / neutral pending (CI running) | deliberately hue-less-feeling: pending is not a call to action | +| ink ramp | everything else | | + +Why: an audit measured green carrying 7 meanings and red shipping *more +saturated than the brand accent* — hue overload is why "the colors weren't +obvious." Tokens are deliberately tempered (chroma below brand); dark-mode +values are re-derived, not brightened (the old dark amber was highlighter +yellow at 84% lightness). + +**Never a background wash for state.** A filled color area out-competes +titles at any opacity — salience is form, not volume. Three progressively +softer washes all failed before this became a rule: put the signal on the +smallest fact-bearing element and change its *form*. + +## The encoding ladder + +Prefer the highest rung that can carry the meaning: + +1. **Position/structure** — cards group under one-word section headers + (`rowWord` in `model/actions.ts`): RE-STAMP, REVIEW, AWAITING RE-CR… + Position is preattentive; this deleted per-card status badges entirely. +2. **Form** — solid vs outlined vs empty on the same mark. +3. **Glyph** — universally-read symbols only; never an invented code. +4. **Color** — as a modifier on the above, per the table. + +Cards are badge-less: `avatar · title · repo# · age · flags | rail`. The +repo#number is the one door into the *full-state* popover (state sentence, +your move, sign-off names, CI word, branch, claim/rotation). The verb lives +in the section header, not on the card. + +**Topical doors, not one mega-popover.** Each rail mark opens its own +detail popover scoped to that mark: the CI bar → the per-check list, the +CR/QA pips → per-signer names and times, the weight letter → the exact +/− +diff, the age line → opened/last-activity. Detail lives one hover from the +mark that hints at it; the repo# door summarizes all of it in prose. This +split is deliberate — merging every +fact into one popover would bury the answer to the question the user's +cursor is already pointing at. + +**Closed rows are receipts, and there is exactly one closed anatomy** +(`ClosedRow`): merged/closed badge (its own popover door — a closed pull's +only remaining fact is when it landed) · avatar · title · repo# · age. No +rail — nothing is left to act on. Every lens that shows closed pulls renders +this component; never a second hand-rolled closed card. + +**Every card that shows a PR is a board row** — fold contents, popover +previews, any future one-off surface. Same element order, same marks, same +doors. A context may *add* (a "why this one" footnote, claim buttons) but +never reorder or restate — inline status text on a card is always a +regression. + +**A button that acts on a list acts on the list the user sees — and once +they fully agree, the button is redundant.** The retired "Deal me one" +feature walked the whole arc: first its hidden ranking diverged from the +queue's (a least-surprise bug), so the queue adopted the deal's exact +score; then the button could only ever hand you the top visible card of +the lane directly under it, so it was removed entirely. The rule that +remains: a ranked lane IS the recommendation — put pick logic in the +lane's ordering and explain it behind the sub-line, never inside a control +with its own private order. + +**Workflow verbs are first-class — and own no geometry.** Claim and +Snooze are the board's two gestures; the rail's right edge is the +board's strongest column (the age numeral caps it) and belongs to DATA +alone. So the verbs float just left of the rail, fading in on row hover +or focus — opacity only, the board's one reveal mechanism; motion stays +reserved for state changes — on a muted backdrop that keeps them legible +over the meta text beneath. Nothing is reserved, nothing shifts, every +fact column stays flush. Pointer-events follow the fade, so an invisible +word can never steal a click. Claim and Release wear brand (the +invitation and its undo); Snooze, ink. Each chip follows ONE rule: it +STANDS when it records a choice you made — a claimed row always shows +"Release", a snoozed row always shows "Unsnooze", in place, no hover — +and reveals on approach when it merely OFFERS one ("Claim", "Snooze"). +The state and its exit are the same pixel: no state glyph (the hand +icon was retired outright), no meta flag restating it (tried and cut — +the standing chip already IS the record). If both states are yours, +both chips stand. Touch has no hover: offers live in the kebab there, +labeled and carrying the same toggled words; standing exits remain +visible everywhere. A verb renders only where it acts (Snooze on +Review alone); utilities stay in the kebab at every width, verbs atop +its menu. Failure modes this design retires, in order tried: four +identical icons (equal salience, unequal decisions); sixty standing +words down a lane (texture where quiet belongs); and a reserved +invisible slot at the row's end (it pushed the age column off the right +edge and paid dead space for words that weren't there — a place is only +worth standing if something visible stands in it). + +**Work-in-progress lives inline, not in a popover.** A popover is for +glancing (state detail, signatures, rankings) and rightly dies on any +outside click. Anything the user is mid-way through renders in the board's +own flow, full-width so rows keep the rail geometry, dismissed only +explicitly. (The retired deal feature learned this the hard way: its +popover era dismissed a commitment-in-progress on any stray click.) + +**Ranked lanes explain themselves in three quiet layers**: the sub-line +says the ordering in one plain sentence (and is itself the hover-door to +the full story — existing text becomes interactive, no info-icon chrome); +each card's state popover carries a "why it's up next" line; and every +fold's label glosses what lands in it on hover. If a user has to ask why +a card is where it is, one of these layers is missing. + +## Identity vs standing + +**The silhouette and the corner mark** — the author-identity system, two +channels and no ring: + +- **Shape answers person-or-machine.** People are circles; bots (a + `[bot]` login or config.json's `bots` list) are rounded-square tiles, + the app-icon idiom Slack and GitHub already taught. No glyph, no hue: + the outline is the whole mark, so it reads at 16px and in peripheral + vision. +- **The bottom-right corner answers what-this-person-is-to-you, in one + of two glyphs.** A small (9px) filled brand heart on a teammate — + someone on one of your rosters; on your own avatar, a larger + (13px/11px) brand star seated ON the rim with a transparent bite + masked out of the face — your silhouette is visibly broken, so your + rows are findable by *form* before the star even resolves. The star + means exactly one thing now: you. You are the vocabulary's largest + case, escalated by size and a broken outline, never by fill. The bite + is a mask, not a painted stroke, so it stays correct over any row + background (hover, fresh flash). +- Static always: identity, not state. Never animates. +- **The controls are retired, not just renamed.** There is no more + "starred repo" or "starred person" toggle: repo relevance is inferred + from the current board (repos you've authored or stamped on), and + roster membership is edited only on the Team lens, not from a row or + filter-row button. + +Rejected on the way here, in order: the plain brand ring (selection +halo), a corner dot (online presence), a brand repo#number (reads as a +link), v1's full star-substitution coin (loses the face), +star-badge-riding-ring (two marks negotiating one corner), and the +seated-star seal itself — a parted ring with the star in the opening — +which shipped, then died on the owner's read ("I don't like the seal at +all"): even parted, ring-ness reads as chrome around the face rather +than a mark of it. From the 20-concept sweep that followed, the +runner-ups were a pure size bump (your face ~5px larger; zero new +vocabulary but marginal in far periphery) and a brand mat behind your +avatar (unmissable but the board's first filled color area on rows). +The corner-bot-glyph variant of full unification lost to the square +silhouette: a 9px machine glyph needs bespoke drawing to survive +rasterization, and shape gets the same distinction free. + + +A mark has two layers: the **glyph names what it is; the treatment names its +condition.** Encode state transitions by modifying a dimension of the +existing mark, not by swapping symbols: + +- sign-off stamp: **solid ✓ disc** = stands · **outlined ✓** = approved once, + a push lapsed it · **empty ring** = never approved. (Rejected on concept: + ↻ read as *loading*, ⊘ read as *cancelled* — symbols carry categorical + connotations; match the category, not the vibe.) + +## The rail's shape taxonomy + +One shape family per metric — a second circle would read as an unlabeled +member of the sign-off family (it did, when weight was a dot): + +| Shape | Metric | Behavior | +|---|---|---| +| circle marks (14px SVG masks, `.pip` for humans; `.ci-ring`/`.ci-disc` for CI) | CI/CR/QA sign-off | ONE geometry for all three reviewers, but **check glyphs are reserved for humans**: CR/QA pips carry the ✓ (solid stands, outlined lapsed, empty needed); CI is a glyphless circle — the machine's mark (2026-07: the knocked-out ✗ disc was retired as louder than any human mark; mass and color carry the alarm now). CI's three standings, all animated with purpose: a **red ring with a center dot** = failing (always binary — a 1-of-25 failure must read as loudly as 25-of-25, so the fraction lives in the popover, never the mark; it lands once with a 320ms scale-settle, then holds still — an alarm that keeps moving is a nag. A solid red disc was tried and cut as a blob: the dot is the smallest mass that still says alarm, hollow like the rest of the machine family, and a form apart from the green passed ring, not just a hue); a **slate ring sweeping clockwise** = running, the sweep being the completed share of checks (breathing at 2.4s so a live run reads as alive; `--sweep` is a registered `@property` so share changes glide); a **closed green ring** revealed on row hover = passed (invisible at rest — no news is good news). It does not draw itself on reveal: **animation is reserved for state CHANGES** (a share advancing, a failure landing), and hovering isn't a change — a draw-on-hover was tried and cut for exactly this reason. Reduced motion: no breathe, no land. All 14px. **The 18px alarm tier is retired**: it earned its size when the rail was crowded with the weight ruler; in the quiet single-line rail, red-as-the-only-red-mark is already the loudest thing on the rail. In wrapped narrow rails (≤520px containers) the quiet/no-check CI slot collapses instead of reserving a hole — cross-row alignment only exists in wide lanes | +| quiet text letter (11px, same fixed slot as the CI/CR/QA label) | review weight | XS/S/M/L/XL — never a "?": the server guarantees diff stats on every open pull (a list item arriving without them gets the full pull fetched), so the unknown-size state was removed outright. Not its own shape at all, deliberately: it rides inside the CR cluster (`CR` label · weight letter · pips) instead of inventing a fourth family or drawing a ratio strip. A prior 4px fill-doubles-per-class strip under the whole sign-off row was retired (2026-07): the owner found it visually loud and it only ever said what a letter already says. Same popover survives the move — the effort word, the exact +/− diff, and the "Filter to X PRs" action | +| the row's baseline (2px hairline, bottom edge) | age / starvation | **gated, not always-on**: nothing below the aging threshold, then a grey hairline — a deepened stretch of the divider the row already has, never a drawn bar. **Relative, not thresholded**: the board's longest-open pull sets the full track and the deepest tint (ultra-light grey → the full border color, deliberately no darker than the row dividers it extends); every other row is a fraction of the oldest, on a square-root curve so the actionable young/mid range stays legible even when one ancient PR sets the top (age doesn't feel linear — 2→5 days reads big, 60→63 as nothing). **Grey only** — the amber version was disruptive and a red plateau before it read as "broken" everywhere; age is a quiet fact, urgency lives in the queue's ranking and the numeral's font weight. The quiet day count floats right in the meta line, capping the track. **The line is also a door** (2026-07): a taller invisible hit strip (10px, sized to the line's own drawn fraction, not the full row) makes a thin target hoverable without pixel-hunting; hover or focus grows the line to an 8px band and opens the same age popover the numeral shows (opened X ago, last activity Y ago, the relative-to-the-oldest line) — one popover body, two doors, so they can't drift apart | + +The rail is one instrument, one line: `CI` then the `CR` cluster (label, +weight letter, sign-off pips) then the `QA` cluster (label, pips). Age +lives on the row's own bottom edge, not in the rail — the track is a line +the row already had, now a hover door in its own right. When a signal moves +onto a better mark, remove it from the old one in the same change, or the +card gets louder instead of clearer. + +Marks are drawn as SVG masks / CSS geometry, never font glyphs — a text ✓ +at 10px is at the mercy of the platform rasterizer (a struck-through ✓ was +conceptually perfect and rendered as a blob). + +**Salience is relative to its era.** A mark's loudness is set by its +neighbors, not by its own pixels: the 18px CI alarm was right next to a +112px ruler and shouting once the ruler died. After removing or quieting +anything in a region, re-audit what now reads loudest there — escalations +earned in a crowded layout rarely survive a quiet one. + +**A boolean alarm is never a proportion.** Encoding "broken" as a share +(a red arc sized failing/total) makes one failure out of 25 a 4% sliver — +invisible for the state that most needs seeing. Alarms are binary at full +strength; continuous encodings (the CI progress ring's sweep, the age +line's length) are reserved for facts that are genuinely continuous. The +counts live in the popover. + +**One mark can also carry a relation's two standings.** The stack +connector's full elbow means "child of the row above"; the same line +truncated to an 8px stub means "child of something that isn't here." When +a relationship's other end may be off-screen, truncate the mark rather +than inventing a second vocabulary — the full form teaches the stub. + +### Icons + +One family: every icon on the board renders through `lucide-react` (ISC, +per-icon imports so tree-shaking holds) via the single wrapper in +`components/Icon.tsx` — 14px for inline/action marks, 16px for header chrome, +lucide's own stroke weight never overridden (a second weight would be a second +family). A 2026-07 icon audit found four different coordinate grids, fill and +stroke mixed on what should've been one glyph language, and a dozen +platform-rendered text glyphs standing in for marks (★/☆, ▸/▾, ✕, ◆) — all +retired in the same pass. **Text glyphs are banned for marks app-wide now**, +not just on the rail; if a mark needs a new glyph, it comes from lucide. + +Two things stay outside that family on purpose: the rail's CI/CR/QA pips +(`.pip`, above) are CSS masks, not lucide imports — one shape family, sized +and animated in ways an icon library's fixed viewBox can't do, and the +doctrine that governs them predates this pass and isn't part of it. The +`EmptyState` animated draw-check (bits.tsx) is a sanctioned hand-drawn +set-piece: it already speaks lucide's own stroke-and-round-cap language, and +its one-shot draw-on animation is bespoke to this exact SVG, not a lucide +icon with a class bolted on. + +## Layout invariants + +- The metric rail is a vertically-centered right column on wide rows + (mail-client anatomy); in narrow columns (≤520px container query) it wraps + to a full-width line, chips flowing left. +- Fixed-width slots keep rail columns aligned down a list; empty states + render invisible placeholders, not nothing. +- Nothing truncates except the wait-word popover source text; density comes + from geometry (compact mode), never from hiding text. +- Ratio marks need a **fixed extent**: a track that stretches with its cell + makes fractions incomparable across rows. +- **Counts are always quiet**: `tabular-nums` in the surrounding text color, + never bold ink — a count is a fact, not an alert. (Fold counts once shipped + bold and read as more urgent than the lane titles above them.) +- **One header system, three tiers**, each defined once: `BoardColumn` + (collapsible column panel) > `GroupHeader` (sticky lane title) > + `Fold` (the board's ONE subsection: a collapsible eyebrow band). + Every subdivision below a lane title is a Fold — word groups inside + "Waiting on you", the rest-of-the-board ledger, a person's other PRs, + Ci's per-check bands. The former fourth tier (non-folding word + sub-headers vs chevron-and-dot disclosure rows) merged into it: two + grouping languages on one board meant the reader learned both. + `eyebrowText` (exported from Lane.tsx) is the band's type treatment; + band labels outside a Fold ("Pick up next", "Check health") share the + constant, never a hand-rolled copy. +- **The Fold contract**: label speaks the row-word vocabulary (brand for + a do-word — the next step is yours; muted for everything that waits), + `· count` after it, a 12px chevron as the one disclosure affordance, + no status dot (the rows inside carry their own pips — a second color + code on the band was redundant weight). Clicking anywhere on the band + toggles, including the label; hovering the label opens its + one-sentence gloss. Primary lanes greet you with groups open; ledger + groups rest closed, so a quiet stack of bands reads as a table of + contents. An explicit open/closed choice is remembered per fold id. + +## Settings & configuration + +- **A control lives where its effect is visible.** Notification prefs + behind the bell, filter defaults inside their filters ("Make this my + default" when the session differs), team and repos edited on the board + surfaces that show them. The Settings panel is the home of last resort, + not the junk drawer. +- **Identity data is not a setting.** Your team, repos, and regions are + workspace data with their own editors; embedding full pickers in a + preferences panel cost 31% of its scroll and made it a "large list" + (owner's words, then measured: 2,846px, 20% visible at once). +- **The panel must be seeable whole.** The test for done: a first-time + user can enumerate every setting without scrolling much; verbs (refresh, + reset) and rarely-touched timers fold into one Advanced disclosure, + destructive action last. +- **Two knobs on one concept is one too many.** Derived values (rot = + 2.5 × warn) beat sibling fields nobody tunes independently. +- **Sticky headers over scrolling interactive content need an explicit + z-index and an opaque background** — every `.hit` control is positioned + and will paint over an unranked sticky header in DOM order (this + shipped as a real mobile bug). + +## Feedback & celebrations + +- **Only cheer what the user caused.** Board-relative facts improve + passively (your rank climbs when someone else's reviewed PRs merge + away); a celebration gated only on the fact's transition fires "at + random" from the user's seat. Require the user's own action in the + gate (climbing demands your own stamp count grew). +- **Nags are once per subject** with an explicit refire rule (overtaken + refires only after you reclaim and lose the spot again); dedupe keys + name the subject, not the tick. + +## Copy rules (static text is part of the visual system) + +- **Say the thing, don't be clever.** Lane names state their contents + plainly: "Waiting on you" / "Waiting on others", never a metaphor the + reader has to decode ("Your move" tested badly — some developers didn't + parse the chess reference, and clever-compressed titles read as + AI-written). +- **One concept, one word, everywhere it appears.** The same underlying + fact must use the same word in the lane title, the group eyebrow, the + State filter, the notification title, and the settings toggle (an owed + re-stamp is "Re-stamp" / "Re-stamp owed" on every surface; it was once + also "Re-review owed"). Before adding copy, grep for the concept's + existing word. +- **Team vocabulary is native, not jargon.** CR, QA, stamp, re-stamp, + rebase come from this team's own workflow (v1 heritage, `cr_req` in the + DB) and stay. What goes: internal engineering words the reader never + chose — "scope" (say "filters"), "starved" (say how long it waited), + "lens" (say "tab"), "wire" (say where the data comes from), codenames + without their plain gloss ("cryo" → "parked"). +- **No em dashes in rendered copy.** Comma, period, semicolon, colon, or + the house "·" separator. (Comments may keep them; the reader never sees + comments.) +- **Every curated lane's sub-line is a door** (`SubDoor` in Lane.tsx): the + visible sentence states the ordering, hovering it opens what lands in + the lane and how it's ranked. Every group eyebrow glosses itself the + same way (WORD_GLOSS). The PR title is a door too: hovering the + visible words previews the description, rendered and sanitized + (markdown.ts, lazy chunk) — the "can I act on this?" read without + leaving the board. Hover only, never click-pin: the title's click + already means "open the PR". An empty description gets no door. A control that sits away from the thing it acts + on names that thing in its own label, so the connection survives the + distance. The test for any new mark, header, or lane: a + developer who has never opened the legend can decode it from the screen + alone. The legend documents; it never teaches. + +## Verification discipline (how design changes get accepted here) + +- Judge at **real render px** and zoomed, in-situ beside real neighbors, in + **both themes** — on the dummy board (`npm run dev:dummy`). +- **The dummy board is the design bench: every mark's every standing must + be permanently visible on it.** Viewer-relative lanes can structurally + hide a state (a stack only nests when its members share one list, so no + chain ever nested until the fixture was synthesized with uniform state). + When a state can't occur naturally in the fixture, synthesize it + deterministically and comment why. +- Prototype candidates with throwaway DOM/JS mutation in the live page; + let the owner pick from *rendered* candidates, not descriptions. +- For any salience claim, do the arithmetic (area × contrast) — one + "inverted salience" design shipped with the math actually backwards. +- The legend (`?` panel) is updated in the same change as any vocabulary + change, but if a mark only works with the legend open, keep iterating. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 00000000..911c05ce --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,43 @@ +# Pulldasher — Product Context + +register: product + +## What this is + +A PR review hub for iFixit engineers: the between-tasks glance that answers +"what can I act on, and what's the best next pickup?" in under a second. +Used many times a day, briefly, by people who already know the codebase — +familiarity and speed over novelty, always. + +## Users + +iFixit engineers (~20 reviewers) triaging their review queue between tasks. +Desktop-first on wide monitors; both light and dark themes are first-class. +The dummy board (`npm run dev:dummy` in `frontend-v2/`) is the design bench. + +## Design principles (in the owner's words) + +- "A minimal UI that just feels like it was meant to work — but indicate + things instead of hiding them." Subtle is good; invisible is a bug. +- "No news is good news." Healthy states show nothing at rest (the CI bar is + invisible when green, revealed on hover). Color on the board means + *look here* — if everything is quiet, everything is fine. +- Actionability first: the page answers "what's my move" before "what exists." + Position (which section a card sits in) is the primary state encoding. +- Think Rams / Ive: the mark should *be* the thing it represents, self-evident + without the legend. The legend documents the vocabulary; it must never be + required to read it. + +## Anti-references + +- Badge soup: rows wearing 3+ colored chips each. v2 shipped this once and + the owner's eyes "went right to the badges instead of the titles." +- Dashboard-as-fruit-salad: saturated status colors on every row. +- Private codes: abstract marks (colored squares) that need the legend. +- Anything that makes heavy/broken work the loudest thing on a board whose + users are hunting the *easy* pickups. + +## See also + +DESIGN.md (the visual system as implemented), frontend-v2/src/styles.css +(tokens are the source of truth for values). diff --git a/README.md b/README.md index 0453b584..0837f5ab 100644 --- a/README.md +++ b/README.md @@ -146,8 +146,9 @@ Pulldasher is released under the [MIT License](LICENSE/). ### React Frontend -- Hack on the just the UI (no DB needed): `npm frontend:start` - - Then open http://localhost:8080/ - - Or the demo page at http://localhost:8080/pull-card-demo.html -- Hack on both the frontend + backend: `npm frontend:watch` + `npm start` - - Then open http://localhost:{port} where port is from your config.js +The frontend lives in `frontend-v2/` (see its README). From the repo root: + +- Hack on just the UI, no DB needed: `npm run frontend:dummy`, then open the + Vite dev server it prints (a synthetic board, no backend). +- Hack on the frontend against a live backend: `npm run frontend:dev` (proxies + `/token`, the socket, and `/stats-history` to a local `npm start`). diff --git a/app.js b/app.js index 0c79e298..42bf37c7 100644 --- a/app.js +++ b/app.js @@ -1,23 +1,28 @@ -import config from "./lib/config-loader.js"; -import express from "express"; -import bodyParser from "body-parser"; -import expressSession from "express-session"; -import authManager from "./lib/authentication.js"; -import socketAuthenticator from "./lib/socket-auth.js"; -import refresh from "./lib/refresh.js"; -import pullManager from "./lib/pull-manager.js"; -import dbManager from "./lib/db-manager.js"; -import pullQueue from "./lib/pull-queue.js"; -import mainController from "./controllers/main.js"; -import hooksController from "./controllers/githubHooks.js"; -import Debug from "./lib/debug.js"; -import { createServer } from "http"; -import { Server } from "socket.io"; -import { fileURLToPath } from "url"; -import { dirname } from "path"; - -const reqLogger = Debug("pulldasher:server:request"); -const debug = Debug("pulldasher"); +import config from './lib/config-loader.js'; +import express from 'express'; +import bodyParser from 'body-parser'; +import expressSession from 'express-session'; +import authManager from './lib/authentication.js'; +import socketAuthenticator from './lib/socket-auth.js'; +import refresh from './lib/refresh.js'; +import pullManager from './lib/pull-manager.js'; +import git from './lib/git-manager.js'; +import dbManager from './lib/db-manager.js'; +import pullQueue from './lib/pull-queue.js'; +import mainController from './controllers/main.js'; +import hooksController from './controllers/githubHooks.js'; +import statsController from './controllers/stats.js'; +import userNamesController from './controllers/user-names.js'; +import apiController from './controllers/api.js'; +import apiAuth from './lib/api-auth.js'; +import Debug from './lib/debug.js'; +import { createServer } from 'http'; +import { Server } from 'socket.io'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const reqLogger = Debug('pulldasher:server:request'); +const debug = Debug('pulldasher'); const app = express(); const httpServer = createServer(app); @@ -26,90 +31,155 @@ const maxPostSize = 1024 * 1024; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -app.set("view engine", "html"); +app.set('view engine', 'html'); /** * Middleware */ -app.use("/public", express.static(__dirname + "/public")); +app.use('/public', express.static(__dirname + '/public')); app.use(bodyParser.urlencoded({ limit: maxPostSize, extended: false })); app.use(bodyParser.json({ limit: maxPostSize })); app.use( - expressSession({ - secret: config.session.secret, - resave: false, - saveUninitialized: false, - }) + expressSession({ + secret: config.session.secret, + resave: false, + saveUninitialized: false, + }) ); app.use(authManager.passport.initialize()); app.use(authManager.passport.session()); app.use(function (req, res, next) { - reqLogger("%s %s", req.method, req.url); - next(); + reqLogger('%s %s', req.method, req.url); + next(); }); /** * Routes */ authManager.setupRoutes(app); -app.use("/", express.static(__dirname + "/frontend/dist")); -app.get("/token", mainController.getToken); -app.post("/hooks/main", hooksController.main); +// v2 is the primary board at the root; the legacy v1 board runs side-by-side +// under /v1 (its assets are built with a matching /v1/ publicPath). Both share +// the same /token + socket.io API below. The /v1 mount is more specific, so it +// must precede the '/' catch-all static mount. +app.use('/v1', express.static(__dirname + '/frontend/dist')); +app.use('/', express.static(__dirname + '/frontend-v2/dist')); +app.get('/token', mainController.getToken); +app.get('/stats-history', statsController.getHistory); +app.get('/user-names', userNamesController.getNames); +app.post('/hooks/main', hooksController.main); + +// /api/v1: machine-to-machine JSON for the review skills, Bearer-authed with +// the caller's own GitHub token (see lib/api-auth). Independent of the +// cookie-session gate -- setupRoutes never registers these paths, so the +// session `auth` middleware doesn't run for them. +app.get('/api/v1/me', apiAuth, apiController.getMe); +app.get('/api/v1/pulls', apiAuth, apiController.getPulls); -debug("Loading all recent pulls from the DB"); +// Warm the bot-login cache (used to tell a pulldasher claim apart from a +// GitHub-UI self-request) before any webhook or socket traffic needs it. +// Memoized in git-manager, so this just avoids the first caller paying for +// the lookup. +git.getBotLogin(); + +debug('Loading all recent pulls from the DB'); dbManager - .getRecentPulls(pullManager.getOldestAllowedPullTimestamp()) - .then(function (pulls) { - debug("Loaded %s pulls", pulls.length); - pullQueue.pause(); - pulls.forEach(function (pull) { - pullManager.updatePull(pull); - }); - pullQueue.resume(); - }) - .then(function () { - debug("Refreshing all open pulls from the API"); - refresh.openPulls(); - }) - .done(); - -/* -@TODO: Update pulls which were open last time Pulldasher ran but are closed now. -dbManager.closeStalePulls(); -*/ + .getRecentPulls(pullManager.getOldestAllowedPullTimestamp()) + .then(function (pulls) { + debug('Loaded %s pulls', pulls.length); + pullQueue.pause(); + pulls.forEach(function (pull) { + pullManager.updatePull(pull); + }); + pullQueue.resume(); + }) + .then(function () { + debug('Refreshing all open pulls from the API'); + refresh.openPulls(); + }) + .done(); //==================================================== // Socket.IO const io = new Server(httpServer); -io.on("connection", function (socket) { - var unauthenticated_timeout = - config.unauthenticated_timeout !== undefined - ? config.unauthenticated_timeout - : 10 * 1000; - - var autoDisconnect = setTimeout(function () { - socket.disconnect(); - }, unauthenticated_timeout); - - socket.once("authenticate", function (token) { - // They did respond. No need to drop their connection for not responding. - clearTimeout(autoDisconnect); - - var user = socketAuthenticator.retrieveUser(token); - if (user) { - socket.emit("authenticated"); - pullManager.addSocket(socket); - } else { - socket.emit("unauthenticated"); +io.on('connection', function (socket) { + var unauthenticated_timeout = + config.unauthenticated_timeout !== undefined ? config.unauthenticated_timeout : 10 * 1000; + + var autoDisconnect = setTimeout(function () { socket.disconnect(); - } - }); + }, unauthenticated_timeout); + + socket.once('authenticate', function (token) { + // They did respond. No need to drop their connection for not responding. + clearTimeout(autoDisconnect); + + var user = socketAuthenticator.retrieveUser(token); + if (user) { + socket.user = user; + socket.emit('authenticated'); + pullManager.addSocket(socket); + } else { + socket.emit('unauthenticated'); + socket.disconnect(); + } + }); + + socket.on('refresh', function (repo, number) { + refresh.pull(repo, number).catch(function (err) { + console.error( + 'Socket "refresh" failed for %s#%s: %s', + repo, + number, + (err && err.message) || err + ); + }); + }); + + // GitHub's requested_reviewers is now the only claim state (see + // review_requests on the pull payload). `ttlMs` may still arrive from + // older clients that used to size a claim's expiry; it's meaningless now + // and ignored. Requesting the reviewer on GitHub, then refreshing the pull + // from the API, is what makes the claim show up for every client -- there's + // no separate broadcast to do here. + socket.on('claimReview', function (repo, number) { + if (!socket.user) { + return; + } + git.requestReviewer(repo, number, socket.user.username) + .then(function () { + return refresh.pull(repo, number); + }) + .catch(function (err) { + console.error( + 'Socket "claimReview" failed for %s#%s (user %s): %s', + repo, + number, + socket.user.username, + (err && err.message) || err + ); + }); + }); - socket.on("refresh", function (repo, number) { - refresh.pull(repo, number); - }); + socket.on('releaseReview', function (repo, number) { + if (!socket.user) { + return; + } + git.removeReviewer(repo, number, socket.user.username) + .then(function () { + return refresh.pull(repo, number); + }) + .catch(function (err) { + console.error( + 'Socket "releaseReview" failed for %s#%s (user %s): %s', + repo, + number, + socket.user.username, + (err && err.message) || err + ); + }); + }); }); -debug("Listening on port %s", config.port); +debug('Listening on port %s', config.port); httpServer.listen(config.port); diff --git a/config.example.js b/config.example.js index de99381a..9513c55a 100644 --- a/config.example.js +++ b/config.example.js @@ -3,184 +3,204 @@ var port = process.env.PORT || 3000; // Support text emojis and all unicode emojis / symbols (as of Dec 2018) // See: https://www.regextester.com/106421 var emoji = - "(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])"; -var emojiText = ":[^\n:]+:"; -var signature = "(" + emojiText + "|" + emoji + ")"; + '(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])'; +var emojiText = ':[^\n:]+:'; +var signature = '(' + emojiText + '|' + emoji + ')'; module.exports = { - // This is the title of the window & navbar heading - title: "Pulldasher", + // This is the title of the window & navbar heading + title: 'Pulldasher', - // This is the port Pulldasher will run on. If you want to have multiple - // instances of Pulldasher running on the same server, just assign them - // different ports. - port: port, + // This is the port Pulldasher will run on. If you want to have multiple + // instances of Pulldasher running on the same server, just assign them + // different ports. + port: port, - // The use of the Github API in Pulldasher is slightly unconventional. - // Rather than using a token for the currently-logged-in user to access the - // API, we use one token belonging to the organization for everyone. This is - // driven by Pulldasher's back-end/front-end separation. Because the backend - // has to be able to access the API even when no one is logged into the front - // end, it can't use the users' tokens. The user logins are used only for - // determining permissions and the active user. - // - // To use Pulldasher, you'll need to set up the following on GitHub: - // 1. A GitHub OAuth Application for your organization (see Settings > OAuth - // applications on GitHub) - // - The homepage URL will be the URL at which Pulldasher is available on - // your machine - // - The authorization callback URL will be the URL below under - // callbackURL. - // 2. An API token that has access to the repo you're going to track (see - // Settings > Personal access tokens on GitHub) - // 3. A GitHub webhook on the repo you want to monitor (Settings (on the - // repo) > Webhooks > Add webhook) - // - The Payload URL should be the externally-visible URL of the Pulldasher - // instance with '/hooks/main' appended and a GET param named `secret` - // containing the secret from `hook_secret` below. - // - Content type should be `application/x-www-form-urlencoded` - // - Choose to be sent individual events, and then check the Issue comments, - // Issues, Pull requests, Pull request reviews, Pull request review - // comments, Pushes, and Statuses boxes - github: { - // Get this from the GitHub application setup page. - clientId: "your github application client id", - secret: "your github appliction secret", - // Where GitHub will send the user's browser after authentication. - callbackURL: "http://localhost:" + port + "/auth/github/callback", - // An API token for the backend to make API requests with. - token: "oauth api token for server-side api calls", - // How much of the token's hourly GitHub API quota to reserve for live - // webhook/socket traffic. Bulk backfills (bin/refresh-*) pace themselves to - // stay above this floor and pause when remaining quota hits it, so a large - // backfill never starves normal operation. Defaults to 1000 if omitted. - bulkReserve: 1000, - // This will need to be the same secret you use on the Webhooks page for - // the repo Pulldasher is going to monitor. - hook_secret: - "some random string to use (?secret=oxwm5gks) to 'secure' github hook handlers", - // Limit access to specific users or teams. - requireOrg: "Limit access to users belonging to this github organization", - requireTeam: - "[Optional] Limit access to users belonging to this team name within the above github organization, uses the slug version of the team name, i.e. 'some-team' from @SomeOrg/some-team", - }, - session: { - secret: "secret for signing session cookies", - }, + // The use of the Github API in Pulldasher is slightly unconventional. + // Rather than using a token for the currently-logged-in user to access the + // API, we use one token belonging to the organization for everyone. This is + // driven by Pulldasher's back-end/front-end separation. Because the backend + // has to be able to access the API even when no one is logged into the front + // end, it can't use the users' tokens. The user logins are used only for + // determining permissions and the active user. + // + // To use Pulldasher, you'll need to set up the following on GitHub: + // 1. A GitHub OAuth Application for your organization (see Settings > OAuth + // applications on GitHub) + // - The homepage URL will be the URL at which Pulldasher is available on + // your machine + // - The authorization callback URL will be the URL below under + // callbackURL. + // 2. An API token that has access to the repo you're going to track (see + // Settings > Personal access tokens on GitHub) + // 3. A GitHub webhook on the repo you want to monitor (Settings (on the + // repo) > Webhooks > Add webhook) + // - The Payload URL should be the externally-visible URL of the Pulldasher + // instance with '/hooks/main' appended and a GET param named `secret` + // containing the secret from `hook_secret` below. + // - Content type should be `application/x-www-form-urlencoded` + // - Choose to be sent individual events, and then check the Issue comments, + // Issues, Pull requests, Pull request reviews, Pull request review + // comments, Pushes, and Statuses boxes + github: { + // Get this from the GitHub application setup page. + clientId: 'your github application client id', + secret: 'your github appliction secret', + // Where GitHub will send the user's browser after authentication. + callbackURL: 'http://localhost:' + port + '/auth/github/callback', + // An API token for the backend to make API requests with. + token: 'oauth api token for server-side api calls', + // How much of the token's hourly GitHub API quota to reserve for live + // webhook/socket traffic. Bulk backfills (bin/refresh-*) pace themselves to + // stay above this floor and pause when remaining quota hits it, so a large + // backfill never starves normal operation. Defaults to 1000 if omitted. + bulkReserve: 1000, + // This will need to be the same secret you use on the Webhooks page for + // the repo Pulldasher is going to monitor. + hook_secret: "some random string to use (?secret=oxwm5gks) to 'secure' github hook handlers", + // Limit access to specific users or teams. + requireOrg: 'Limit access to users belonging to this github organization', + requireTeam: + "[Optional] Limit access to users belonging to this team name within the above github organization, uses the slug version of the team name, i.e. 'some-team' from @SomeOrg/some-team", + }, + session: { + secret: 'secret for signing session cookies', + }, - // When true (default), GitHub PR reviews with state APPROVED count as CR - // signoffs without requiring a CR :emoji: tag in the review body. - useGithubApprovalForCr: true, + // When true (default), GitHub PR reviews with state APPROVED count as CR + // signoffs without requiring a CR :emoji: tag in the review body. + useGithubApprovalForCr: true, - // List of repositories for pulldasher to watch. - repos: [ - // Just listing the repo as a string indicates pulldasher should consider - // CI passing if all the statuses that exist are passing and that there - // is at least one. - "owner/repo", - // Listing them like this allows being more explicit with the commit - // statuses - { - name: "owner/otherRepo", - requiredStatuses: ["tests", "build", "codeClimate"], - ignoredStatuses: ["coverage"], - // Hides pulls on this repo on page load, users can unhide them with the repo filter - hideByDefault: true, - }, - ], + // List of repositories for pulldasher to watch. + repos: [ + // Just listing the repo as a string indicates pulldasher should consider + // CI passing if all the statuses that exist are passing and that there + // is at least one. + 'owner/repo', + // Listing them like this allows being more explicit with the commit + // statuses + { + name: 'owner/otherRepo', + requiredStatuses: ['tests', 'build', 'codeClimate'], + ignoredStatuses: ['coverage'], + // Hides pulls on this repo on page load, users can unhide them with the repo filter + hideByDefault: true, + }, + ], + + // Deployment config that used to live in the frontend's static config.json; + // it now lives here (the one source) and is delivered to the board over the + // socket, and read by the /api/v1 endpoint. Both are optional. + // + // Bot logins beyond the `[bot]` suffix GitHub Apps carry (so pulldasher can + // fold/skip their PRs). Omit or [] for suffix-only detection. + bots: ['renovate-bot'], + // Label title -> review-weight bucket (XS|S|M|L|XL). A PR carrying one of + // these labels takes that weight instead of the diff-size guess, so an auto + // weight label (and any manual override) drives the board and the API. Omit + // or {} to fall back to the size heuristic. + weightLabels: { + 'size: XS': 'XS', + 'size: S': 'S', + 'size: M': 'M', + 'size: L': 'L', + 'size: XL': 'XL', + }, - // The usual MySQL stuff. Like every other MySQL webapp, basically. - // You will need to source the `schema.sql` file in the database to create - // all the tables that Pulldasher expects. - mysql: { - host: "mysql remote host URL", - db: "database name", - user: "username", - pass: "password", - }, - // The tags Pulldasher uses to determine how many CR and QA are required for - // a given pull. The names are currently non-negotiable as far as the - // frontend is concerned, but the regex may be changed to your heart's - // content. - body_tags: [ - { - name: "cr_req", - regex: /\bcr_req ([0-9]+)\b/i, - default: 2, - }, - { - name: "qa_req", - regex: /\bqa_req ([0-9]+)\b/i, - default: 1, - }, - { - name: "closes", - regex: /\b(?:close(?:s|d)?|fix(?:es|ed)?|resolve(?:s|d)?) #([0-9]+)\b/i, - default: null, - }, - { - name: "connects", - regex: /\b(?:connect(?:s|ed)? to|connects) #([0-9]+)\b/i, - default: null, - }, - ], - // Tags which indicate a comment is significant and should be parsed. - // The regex may be customized to change the tag used in GitHub. - tags: [ - { - name: "dev_block", - // This regex supports thins like :smile: as well as the actual - // unicode representation of emoticons. Mainly added because github - // started making their autocompletor inject actual unicode emojis - // in the text. - regex: new RegExp("\\bdev_block " + signature, "i"), - }, - { - name: "un_dev_block", - regex: new RegExp("\\bun_dev_block " + signature, "i"), - }, - { - name: "deploy_block", - regex: new RegExp("\\bdeploy_block " + signature, "i"), - }, - { - name: "un_deploy_block", - regex: new RegExp("\\bun_deploy_block " + signature, "i"), - }, - { - name: "QA", - regex: new RegExp("\\bQA " + signature, "i"), - }, - { - name: "CR", - regex: new RegExp("\\bCR " + signature, "i"), - }, - ], + // The usual MySQL stuff. Like every other MySQL webapp, basically. + // You will need to source the `schema.sql` file in the database to create + // all the tables that Pulldasher expects. + mysql: { + host: 'mysql remote host URL', + db: 'database name', + user: 'username', + pass: 'password', + }, + // The tags Pulldasher uses to determine how many CR and QA are required for + // a given pull. The names are currently non-negotiable as far as the + // frontend is concerned, but the regex may be changed to your heart's + // content. + body_tags: [ + { + name: 'cr_req', + regex: /\bcr_req ([0-9]+)\b/i, + // one CR by default, matching prod/dev config.js; a PR sets its own + // cr_req N in the body to require more (or 0 to waive review) + default: 1, + }, + { + name: 'qa_req', + regex: /\bqa_req ([0-9]+)\b/i, + default: 1, + }, + { + name: 'closes', + regex: /\b(?:close(?:s|d)?|fix(?:es|ed)?|resolve(?:s|d)?) #([0-9]+)\b/i, + default: null, + }, + { + name: 'connects', + regex: /\b(?:connect(?:s|ed)? to|connects) #([0-9]+)\b/i, + default: null, + }, + ], + // Tags which indicate a comment is significant and should be parsed. + // The regex may be customized to change the tag used in GitHub. + tags: [ + { + name: 'dev_block', + // This regex supports thins like :smile: as well as the actual + // unicode representation of emoticons. Mainly added because github + // started making their autocompletor inject actual unicode emojis + // in the text. + regex: new RegExp('\\bdev_block ' + signature, 'i'), + }, + { + name: 'un_dev_block', + regex: new RegExp('\\bun_dev_block ' + signature, 'i'), + }, + { + name: 'deploy_block', + regex: new RegExp('\\bdeploy_block ' + signature, 'i'), + }, + { + name: 'un_deploy_block', + regex: new RegExp('\\bun_deploy_block ' + signature, 'i'), + }, + { + name: 'QA', + regex: new RegExp('\\bQA ' + signature, 'i'), + }, + { + name: 'CR', + regex: new RegExp('\\bCR ' + signature, 'i'), + }, + ], - /** - * This is a list of objects describing labels on issues which should be - * converted to properties on the issue. Note that if more than one label - * matches the regex, only one will appear in the property. - */ - labels: [ - { - name: "difficulty", - regex: /^size: [0-9]+$/, - // Take in a string (or null, when a label is deleted), returns the - // new value to be stored: issue[name] = process(label) - process: function (label) { - var match = label ? label.match(/[0-9]+/) : null; - return match ? parseInt(match[0], 10) : null; + /** + * This is a list of objects describing labels on issues which should be + * converted to properties on the issue. Note that if more than one label + * matches the regex, only one will appear in the property. + */ + labels: [ + { + name: 'difficulty', + regex: /^size: [0-9]+$/, + // Take in a string (or null, when a label is deleted), returns the + // new value to be stored: issue[name] = process(label) + process: function (label) { + var match = label ? label.match(/[0-9]+/) : null; + return match ? parseInt(match[0], 10) : null; + }, }, - }, - ], + ], - // Where to store the PID of the pulldasher process when run. - pidFile: "/var/run/pulldasher.pid", - // Setting this to true prints more debugging information. - debug: true, - // The time in ms before an unauthenticated websocket connection times out. - unauthenticated_timeout: 10 * 1000, - // The time in ms before an authentication token times out. - token_timeout: 100 * 1000, + // Where to store the PID of the pulldasher process when run. + pidFile: '/var/run/pulldasher.pid', + // Setting this to true prints more debugging information. + debug: true, + // The time in ms before an unauthenticated websocket connection times out. + unauthenticated_timeout: 10 * 1000, + // The time in ms before an authentication token times out. + token_timeout: 100 * 1000, }; diff --git a/controllers/api.js b/controllers/api.js new file mode 100644 index 00000000..8a679df2 --- /dev/null +++ b/controllers/api.js @@ -0,0 +1,71 @@ +import pullManager from '../lib/pull-manager.js'; +import { deriveAll, isBot } from '../lib/review-model.js'; +import { STATUS_ORDER } from '../shared/dist/index.js'; + +// STATUS_ORDER is the board's canonical bucket order; index it so the default +// response is grouped the same way the board groups, oldest-first within a +// bucket. This is a stable default sort, NOT the reviewer-priority ranking +// (crSort/deal) -- that stays client-side, applied to this list by consumers. +const statusRank = new Map(STATUS_ORDER.map((s, i) => [s, i])); + +/** + * One DerivedPull -> the API record. Metadata and the server-computed + * classification only: no diff (a consumer fetches that from GitHub with the + * same token), and every timestamp is an ISO string straight off the wire. + */ +function toRecord(d) { + const p = d.data; + return { + id: `${p.repo}#${p.number}`, + repo: p.repo, + number: p.number, + title: p.title, + url: `https://github.com/${p.repo}/pull/${p.number}`, + author: p.user.login, + is_bot: isBot(p.user.login), + draft: p.draft, + status: d.status, + ci: d.ci, + ci_failing: d.ciFailing, + weight: d.weight, + age_days: d.ageDays, + starved: d.starved, + signoffs: { + cr: { req: p.status.cr_req, have: d.crHave, by: d.crBy, recr_by: d.recrBy }, + qa: { req: p.status.qa_req, have: d.qaHave, by: d.qaBy }, + }, + dev_blocked_by: d.devBlockedBy, + deploy_blocked_by: d.deployBlockedBy, + head_sha: p.head.sha, + base: p.base.ref, + created_at: p.created_at, + updated_at: p.updated_at, + }; +} + +export default { + /** + * GET /api/v1/pulls -- every open PR on the board, classified server-side + * with the same derive the board runs. Bearer-authed (lib/api-auth). This + * is what lets the what-to-review / batch-review skills make one call + * instead of a direct DB query plus a bash re-implementation of buckets. + */ + getPulls: function (req, res) { + const now = Date.now() / 1000; + const open = pullManager.getPulls().filter(pull => pull.isOpen()); + const records = deriveAll(open, now) + .map(toRecord) + .sort( + (a, b) => + (statusRank.get(a.status) ?? 99) - (statusRank.get(b.status) ?? 99) || + b.age_days - a.age_days + ); + res.json({ server_time: Math.floor(now), count: records.length, pulls: records }); + }, + + /** GET /api/v1/me -- the authenticated caller's login, so a skill can + * confirm its token works and resolve the default reviewer. */ + getMe: function (req, res) { + res.json({ login: req.apiUser.login }); + }, +}; diff --git a/controllers/githubHooks.js b/controllers/githubHooks.js index c11ddfb3..a4aee605 100644 --- a/controllers/githubHooks.js +++ b/controllers/githubHooks.js @@ -1,233 +1,251 @@ -import config from "../lib/config-loader.js"; -import Promise from "bluebird"; -import debug from "../lib/debug.js"; -import Pull from "../models/pull.js"; -import Signature from "../models/signature.js"; -import Comment from "../models/comment.js"; -import Review from "../models/review.js"; -import Status from "../models/status.js"; -import Label from "../models/label.js"; -import refresh from "../lib/refresh.js"; -import getLogin from "../lib/get-user-login.js"; -import utils from "../lib/utils.js"; -import dbManager from "../lib/db-manager.js"; - -const hooksDebug = debug("pulldasher:hooks"); +import config from '../lib/config-loader.js'; +import Promise from 'bluebird'; +import debug from '../lib/debug.js'; +import Pull from '../models/pull.js'; +import Signature from '../models/signature.js'; +import Comment from '../models/comment.js'; +import Review from '../models/review.js'; +import Status from '../models/status.js'; +import Label from '../models/label.js'; +import refresh from '../lib/refresh.js'; +import queue from '../lib/pull-queue.js'; +import getLogin from '../lib/get-user-login.js'; +import utils from '../lib/utils.js'; +import dbManager from '../lib/db-manager.js'; +import git from '../lib/git-manager.js'; -const HooksController = { - main: function (req, res) { - // Variable for promise that will resolve when the hook is known to have - // succeeded or failed. - var dbUpdated; - var comment; - - var secret = req.query.secret; - if (secret !== config.github.hook_secret) { - var m = "Invalid Hook Secret: " + secret; - hooksDebug(m); - console.error(m); - return res.status(401).send("Invalid POST"); - } - - // Begin the webhook decoding - var body; - if (req.headers["content-type"] === "application/json") { - body = req.body; - } else if ( - req.headers["content-type"] === "application/x-www-form-urlencoded" - ) { - body = JSON.parse(req.body.payload); - } else { - return res - .status(400) - .send("Invalid content-type " + req.headers["content-type"]); - } - - var event = req.get("X-GitHub-Event"); - hooksDebug("Received GitHub webhook, Event: %s, Action: %s", event, body.action); - - if (event === "status") { - const updatedStatus = new Status({ - repo: body.name, - sha: body.sha, - state: body.state, - context: body.context, - description: body.description, - target_url: body.target_url, - started_at: body.created_at, - completed_at: body.state == "pending" ? null : body.updated_at, - }); - dbUpdated = dbManager.updateCommitStatus(updatedStatus); - } else if (event === "issues") { - dbUpdated = handleIssueEvent(body); - } else if (event === "pull_request") { - // Promise that resolves when everything that needs to be done before - // we call `updatePull` has finished. - var preUpdate = handleLabelEvents(body); - // New commits may carry a GitHub approval over a clean master merge, so - // after the cheap DB update we re-derive review state from GitHub. - var reconcileAfterUpdate = false; - - switch (body.action) { - case "opened": - case "reopened": - case "closed": - case "edited": - case "merged": - break; - - case "synchronize": - // Clear prior CR/QA signoffs immediately for snappy feedback. Emoji - // CR/QA stay cleared; a carried-over GitHub *approval* is restored by - // the full refresh below (see Signature.parseReview / git-manager). - preUpdate = dbManager.invalidateSignatures( - body.repository.full_name, - body.pull_request.number, - ["QA", "CR"] - ); - reconcileAfterUpdate = true; - } +const hooksDebug = debug('pulldasher:hooks'); - // Update DB with new pull request content. - dbUpdated = preUpdate.then(function () { - return dbManager.updatePull(Pull.fromGithubApi(body.pull_request)); - }); +const HooksController = { + main: function (req, res) { + // Variable for promise that will resolve when the hook is known to have + // succeeded or failed. + var dbUpdated; + var comment; - if (reconcileAfterUpdate) { - // A `synchronize` webhook never triggers a full refresh on its own, - // and Pulldasher has no periodic poll, so re-derive review state from - // GitHub once the cheap DB update lands to pick up an approval GitHub - // kept across the new commit(s). - // - // Fire-and-forget, like every other refresh.pull caller below. Do NOT - // fold it into `dbUpdated`: coupling the webhook's HTTP response to a - // full, serialized refresh risks GitHub's 10s webhook timeout and - // turns a transient refresh failure into a 500 + redelivery storm. - dbUpdated.then(function () { - refresh.pull(body.repository.full_name, body.pull_request.number); - }); + var secret = req.query.secret; + if (secret !== config.github.hook_secret) { + var m = 'Invalid Hook Secret: ' + secret; + hooksDebug(m); + console.error(m); + return res.status(401).send('Invalid POST'); } - } else if (event === "issue_comment") { - if (body.action === "created") { - var promises = []; - - // Parse any signature(s) out of the comment. - var sigs = Signature.parseComment( - body.comment, - body.repository.full_name, - body.issue.number - ); - promises.push(dbManager.insertSignatures(sigs)); - - body.comment.number = body.issue.number; - body.comment.repo = body.repository.full_name; - body.comment.type = "issue"; - comment = new Comment(body.comment); - - promises.push(dbManager.updateComment(comment)); - - dbUpdated = Promise.all(promises); + + // Begin the webhook decoding + var body; + if (req.headers['content-type'] === 'application/json') { + body = req.body; + } else if (req.headers['content-type'] === 'application/x-www-form-urlencoded') { + body = JSON.parse(req.body.payload); } else { - // If the comment was edited or deleted, the easiest way to deal - // with the result is to simply refresh all data for the pull (or - // issue). Otherwise, we'd have to delete or update the comment, - // delete or update any signatures tied to that comment, then - // delete all signatures and re-insert in order them so the - // dev_blocking and such comes out correct. - refreshPullOrIssue(body); + return res.status(400).send('Invalid content-type ' + req.headers['content-type']); } - } else if (event === "pull_request_review") { - if (body.action === "submitted") { - promises = []; - // Parse any signature(s) out of the comment. - sigs = Signature.parseReview( - body.review, - body.repository.full_name, - body.pull_request.number - ); - promises.push(dbManager.insertSignatures(sigs)); + var event = req.get('X-GitHub-Event'); + hooksDebug('Received GitHub webhook, Event: %s, Action: %s', event, body.action); - body.review.number = body.pull_request.number; - body.review.repo = body.repository.full_name; - const review = new Review(body.review); + if (event === 'status') { + const updatedStatus = new Status({ + repo: body.name, + sha: body.sha, + state: body.state, + context: body.context, + description: body.description, + target_url: body.target_url, + started_at: body.created_at, + completed_at: body.state == 'pending' ? null : body.updated_at, + }); + dbUpdated = dbManager.updateCommitStatus(updatedStatus); + } else if (event === 'issues') { + dbUpdated = handleIssueEvent(body); + } else if (event === 'pull_request') { + // Promise that resolves when everything that needs to be done before + // we call `updatePull` has finished. + var preUpdate = handleLabelEvents(body); + // New commits may carry a GitHub approval over a clean master merge, so + // after the cheap DB update we re-derive review state from GitHub. + var reconcileAfterUpdate = false; - promises.push(dbManager.updateReview(review)); + switch (body.action) { + case 'opened': + case 'reopened': + case 'closed': + case 'edited': + case 'merged': + break; - dbUpdated = Promise.all(promises); - } else { - // If the review was edited or deleted, the easiest way to deal - // with the result is to simply refresh all data for the pull. - // Otherwise, we'd have to delete or update the review, delete or - // update any signatures tied to that review, then delete all - // signatures and re-insert in order them so the dev_blocking and - // such comes out correct. - refreshPullOrIssue(body); + case 'review_requested': + case 'review_request_removed': + // These two actions alone carry `requested_reviewer` (the + // login) and `sender` (who did it) -- fields the generic + // pull_request payload below doesn't have -- so record the + // precise at/self metadata directly, memory-only (no + // review_requests column -- see models/pull.js), before the + // generic `Pull.fromGithubApi(body.pull_request)` call reads + // it back out. + preUpdate = preUpdate.then(() => recordReviewRequestMetadata(body)); + break; + + case 'synchronize': + // Clear prior CR/QA signoffs immediately for snappy feedback. Emoji + // CR/QA stay cleared; a carried-over GitHub *approval* is restored by + // the full refresh below (see Signature.parseReview / git-manager). + preUpdate = dbManager.invalidateSignatures( + body.repository.full_name, + body.pull_request.number, + ['QA', 'CR'] + ); + reconcileAfterUpdate = true; + } + + // Update DB with new pull request content. + dbUpdated = preUpdate.then(function () { + return dbManager.updatePull(Pull.fromGithubApi(body.pull_request)); + }); + + if (reconcileAfterUpdate) { + // A `synchronize` webhook never triggers a full refresh on its own, + // and Pulldasher has no periodic poll, so re-derive review state from + // GitHub once the cheap DB update lands to pick up an approval GitHub + // kept across the new commit(s). + // + // Fire-and-forget, like every other refresh.pull caller below. Do NOT + // fold it into `dbUpdated`: coupling the webhook's HTTP response to a + // full, serialized refresh risks GitHub's 10s webhook timeout and + // turns a transient refresh failure into a 500 + redelivery storm. + const syncRepo = body.repository.full_name; + const syncNumber = body.pull_request.number; + dbUpdated + .then(function () { + return refresh.pull(syncRepo, syncNumber); + }) + .catch(function (err) { + console.error( + 'synchronize reconciliation refresh failed for %s#%s: %s', + syncRepo, + syncNumber, + (err && err.message) || err + ); + }); + } + } else if (event === 'issue_comment') { + if (body.action === 'created') { + var promises = []; + + // Parse any signature(s) out of the comment. + var sigs = Signature.parseComment( + body.comment, + body.repository.full_name, + body.issue.number + ); + promises.push(dbManager.insertSignatures(sigs)); + + body.comment.number = body.issue.number; + body.comment.repo = body.repository.full_name; + body.comment.type = 'issue'; + comment = new Comment(body.comment); + + promises.push(dbManager.updateComment(comment)); + + dbUpdated = Promise.all(promises); + } else { + // If the comment was edited or deleted, the easiest way to deal + // with the result is to simply refresh all data for the pull (or + // issue). Otherwise, we'd have to delete or update the comment, + // delete or update any signatures tied to that comment, then + // delete all signatures and re-insert in order them so the + // dev_blocking and such comes out correct. + refreshPullOrIssue(body); + } + } else if (event === 'pull_request_review') { + if (body.action === 'submitted') { + promises = []; + + // Parse any signature(s) out of the comment. + sigs = Signature.parseReview( + body.review, + body.repository.full_name, + body.pull_request.number + ); + promises.push(dbManager.insertSignatures(sigs)); + + body.review.number = body.pull_request.number; + body.review.repo = body.repository.full_name; + const review = new Review(body.review); + + promises.push(dbManager.updateReview(review)); + + dbUpdated = Promise.all(promises); + } else { + // If the review was edited or deleted, the easiest way to deal + // with the result is to simply refresh all data for the pull. + // Otherwise, we'd have to delete or update the review, delete or + // update any signatures tied to that review, then delete all + // signatures and re-insert in order them so the dev_blocking and + // such comes out correct. + refreshPullOrIssue(body); + } + } else if (event === 'pull_request_review_comment') { + if (body.action === 'deleted') { + dbUpdated = dbManager.deleteReviewComment(body.repository.full_name, body.comment.id); + } else { + body.comment.number = body.pull_request.number; + body.comment.repo = body.repository.full_name; + body.comment.type = 'review'; + comment = new Comment(body.comment); + + dbUpdated = dbManager.updateComment(comment); + } + } else if (event === 'check_run') { + const conclusion = body.check_run.conclusion || body.check_run.status; + const state = utils.mapCheckToStatus(conclusion); + + const status = new Status({ + repo: body.repository.full_name, + sha: body.check_run.head_sha, + state: state, + context: body.check_run.name, + description: conclusion, + target_url: body.check_run.html_url, + started_at: body.check_run.started_at, + completed_at: body.check_run.completed_at, + }); + dbUpdated = dbManager.updateCommitStatus(status); } - } else if (event === "pull_request_review_comment") { - if (body.action === "deleted") { - dbUpdated = dbManager.deleteReviewComment( - body.repository.full_name, - body.comment.id - ); - } else { - body.comment.number = body.pull_request.number; - body.comment.repo = body.repository.full_name; - body.comment.type = "review"; - comment = new Comment(body.comment); - dbUpdated = dbManager.updateComment(comment); + if (dbUpdated) { + dbUpdated + .then( + function fulfilled() { + res.status(200).send('Success!'); + }, + function rejected(err) { + console.log(err); + res.status(500).send(err.toString()); + } + ) + .done(); + } else { + res.status(200).send('Success!'); } - } else if (event === "check_run") { - const conclusion = body.check_run.conclusion || body.check_run.status; - const state = utils.mapCheckToStatus(conclusion); - - const status = new Status({ - repo: body.repository.full_name, - sha: body.check_run.head_sha, - state: state, - context: body.check_run.name, - description: conclusion, - target_url: body.check_run.html_url, - started_at: body.check_run.started_at, - completed_at: body.check_run.completed_at, - }); - dbUpdated = dbManager.updateCommitStatus(status); - } - - if (dbUpdated) { - dbUpdated - .then( - function fulfilled() { - res.status(200).send("Success!"); - }, - function rejected(err) { - console.log(err); - res.status(500).send(err.toString()); - } - ) - .done(); - } else { - res.status(200).send("Success!"); - } - }, + }, }; function handleIssueEvent(body) { - hooksDebug("Webhook action: %s for issue #%s", body.action, body.issue.number); - - var doneHandling = handleLabelEvents(body); - - // Always refresh from the API rather than upserting the webhook body - // directly. The body is just a subset of the fields (and for pull requests, - // not even an issue), and it carries no events, which we need to attribute - // labels and to date the current assignment (date_assigned). - // refreshPullOrIssue dispatches pull-vs-issue from the body itself. - return doneHandling.then(function () { - // Not returning here cause we don't want to delay replying to the - // hook with a 200 since we know what needs to be done. - refreshPullOrIssue(body); - }); + hooksDebug('Webhook action: %s for issue #%s', body.action, body.issue.number); + + var doneHandling = handleLabelEvents(body); + + // Always refresh from the API rather than upserting the webhook body + // directly. The body is just a subset of the fields (and for pull requests, + // not even an issue), and it carries no events, which we need to attribute + // labels and to date the current assignment (date_assigned). + // refreshPullOrIssue dispatches pull-vs-issue from the body itself. + return doneHandling.then(function () { + // Not returning here cause we don't want to delay replying to the + // hook with a 200 since we know what needs to be done. + refreshPullOrIssue(body); + }); } /** @@ -238,41 +256,100 @@ function handleIssueEvent(body) { * Note: will return a fulfilled promise if this is not a label event. */ function handleLabelEvents(body) { - var object = body.pull_request || body.issue; - switch (body.action) { - case "labeled": - hooksDebug("Added label: %s", body.label.name); - return dbManager.insertLabel( - new Label( - body.label, - object.number, - body.repository.full_name, - getLogin(body.sender), - object.updated_at - ) - ); + var object = body.pull_request || body.issue; + switch (body.action) { + case 'labeled': + hooksDebug('Added label: %s', body.label.name); + return dbManager + .insertLabel( + new Label( + body.label, + object.number, + body.repository.full_name, + getLogin(body.sender), + object.updated_at + ) + ) + .then(markDirtyIfPull); - case "unlabeled": - hooksDebug("Removed label: %s", body.label.name); - return dbManager.deleteLabel( - new Label(body.label, object.number, body.repository.full_name) - ); - } - return Promise.resolve(); + case 'unlabeled': + hooksDebug('Removed label: %s', body.label.name); + return dbManager + .deleteLabel(new Label(body.label, object.number, body.repository.full_name)) + .then(markDirtyIfPull); + } + return Promise.resolve(); + + // A label change must broadcast on its own: the weight labels drive the + // frontend's CR-weight display, and the only other thing that notifies + // clients after a label event is an incidental updatePull call in the + // pull_request handler — an easy thing for a refactor to scope away. + function markDirtyIfPull() { + if (body.pull_request) { + queue.markPullAsDirty(body.repository.full_name, object.number); + } + } +} + +/** + * Record one `review_requested` / `review_request_removed` webhook's + * metadata onto the in-memory review_requests cache (models/pull.js) -- + * memory-only, no DB write. `body.requested_reviewer` is absent for a team + * review request, which Pulldasher doesn't track per-reviewer, so that's a + * no-op. + */ +function recordReviewRequestMetadata(body) { + const reviewer = body.requested_reviewer; + if (!reviewer) { + return Promise.resolve(); + } + + const repo = body.repository.full_name; + const number = body.pull_request.number; + const login = getLogin(reviewer); + + if (body.action === 'review_request_removed') { + Pull.recordReviewRequestRemoved(repo, number, login); + return Promise.resolve(); + } + + return git.getBotLogin().then(function (botLogin) { + const senderLogin = getLogin(body.sender); + Pull.recordReviewRequested(repo, number, login, { + at: Math.floor(Date.now() / 1000), + self: senderLogin === login || (Boolean(botLogin) && senderLogin === botLogin), + }); + }); } function refreshPullOrIssue(responseBody) { - var repo = responseBody.repository.full_name; - - // The Docs: https://developer.github.com/v3/issues/#list-issues say you can - // tell the difference like this: - if (responseBody.pull_request) { - refresh.pull(repo, responseBody.pull_request.number); - } else if (responseBody.issue.pull_request) { - refresh.pull(repo, responseBody.issue.number); - } else { - refresh.issue(repo, responseBody.issue.number); - } + var repo = responseBody.repository.full_name; + var number; + var refreshed; + + // The Docs: https://developer.github.com/v3/issues/#list-issues say you can + // tell the difference like this: + if (responseBody.pull_request) { + number = responseBody.pull_request.number; + refreshed = refresh.pull(repo, number); + } else if (responseBody.issue.pull_request) { + number = responseBody.issue.number; + refreshed = refresh.pull(repo, number); + } else { + number = responseBody.issue.number; + refreshed = refresh.issue(repo, number); + } + + // Fire-and-forget (see callers): covers the issue_comment / pull_request_review + // edited-or-deleted paths, and the issues-event handler below. + refreshed.catch(function (err) { + console.error( + 'refreshPullOrIssue failed for %s#%s: %s', + repo, + number, + (err && err.message) || err + ); + }); } export default HooksController; diff --git a/controllers/stats.js b/controllers/stats.js new file mode 100644 index 00000000..bedc03a6 --- /dev/null +++ b/controllers/stats.js @@ -0,0 +1,236 @@ +import db from '../lib/db.js'; +import debug from '../lib/debug.js'; +import { isFresh } from '../lib/ttl-cache.js'; +import { respondOrError } from '../lib/controller-utils.js'; + +const statsDebug = debug('pulldasher:stats'); + +// This data moves slowly (it's dominated by history, not the live board), so +// memoize the whole response for a while rather than re-running four +// aggregate queries on every dashboard load. +const CACHE_TTL_MS = 10 * 60 * 1000; +let cache = null; // { at: , body: } + +const MONTH_MS = 30 * 24 * 60 * 60 * 1000; +const DAY_MS = 24 * 60 * 60 * 1000; +const WEEK_MS = 7 * DAY_MS; + +/** + * Opened vs merged pulls per calendar month, last 12 months. + */ +function queryMonthly() { + const since = Math.floor((Date.now() - 12 * MONTH_MS) / 1000); + const q = + "\ + SELECT month, SUM(opened) AS opened, SUM(merged) AS merged FROM ( \ + SELECT DATE_FORMAT(FROM_UNIXTIME(date), '%Y-%m') AS month, 1 AS opened, 0 AS merged \ + FROM pulls WHERE date >= ? \ + UNION ALL \ + SELECT DATE_FORMAT(FROM_UNIXTIME(date_merged), '%Y-%m') AS month, 0 AS opened, 1 AS merged \ + FROM pulls WHERE date_merged >= ? \ + ) x \ + GROUP BY month ORDER BY month"; + + return db.query(q, [since, since]).then(function (rows) { + return rows.map(function (row) { + return { + month: row.month, + opened: Number(row.opened), + merged: Number(row.merged), + }; + }); + }); +} + +/** + * Merge age (time from open to merge) grouped by the day it merged, last 60 + * days: average and max hours, plus how many merged that day. + */ +function queryMergeAgeByDay() { + const since = Math.floor((Date.now() - 60 * DAY_MS) / 1000); + const q = + '\ + SELECT DATE(FROM_UNIXTIME(date_merged)) AS day, \ + AVG(date_merged - date) AS avg_seconds, \ + MAX(date_merged - date) AS max_seconds, \ + COUNT(*) AS merged \ + FROM pulls \ + WHERE date_merged >= ? \ + GROUP BY day ORDER BY day'; + + return db.query(q, [since]).then(function (rows) { + return rows.map(function (row) { + return { + day: formatDate(row.day), + avgHours: round1(row.avg_seconds / 3600), + maxHours: round1(row.max_seconds / 3600), + merged: Number(row.merged), + }; + }); + }); +} + +/** + * Per-pull hours from open to first CR signature, for merged pulls in the + * last 12 months. One row per (repo, number) — used both to compute the + * monthly average in SQL and, in JS, the monthly median (MySQL has no + * MEDIAN aggregate, and the row count here is small enough — thousands, not + * millions — that pulling the raw latencies and sorting in JS is simpler + * than a second, uglier query). + */ +function queryFirstCrLatencies() { + const since = Math.floor((Date.now() - 12 * MONTH_MS) / 1000); + const q = + "\ + SELECT DATE_FORMAT(FROM_UNIXTIME(p.date_merged), '%Y-%m') AS month, \ + (cr.min_date - p.date) AS latency_seconds \ + FROM pulls p \ + JOIN ( \ + SELECT repo, number, MIN(date) AS min_date \ + FROM pull_signatures \ + WHERE type = 'CR' \ + GROUP BY repo, number \ + ) cr ON cr.repo = p.repo AND cr.number = p.number \ + WHERE p.date_merged >= ? \ + AND cr.min_date > p.date"; + + return db.query(q, [since]); +} + +/** + * Average PR duration (open to merge) grouped by ISO week, last 26 weeks. + */ +function queryDurationByWeek() { + const since = Math.floor((Date.now() - 26 * WEEK_MS) / 1000); + const q = + '\ + SELECT YEARWEEK(FROM_UNIXTIME(date_merged), 3) AS iso_yearweek, \ + AVG(date_merged - date) AS avg_seconds, \ + COUNT(*) AS merged \ + FROM pulls \ + WHERE date_merged >= ? \ + GROUP BY iso_yearweek ORDER BY iso_yearweek'; + + return db.query(q, [since]).then(function (rows) { + return rows.map(function (row) { + return { + isoWeek: formatIsoWeek(row.iso_yearweek), + avgHours: round1(row.avg_seconds / 3600), + merged: Number(row.merged), + }; + }); + }); +} + +/** + * Reduces the raw per-pull first-CR latencies (one row per merged pull, as + * returned by queryFirstCrLatencies) into one row per month, with both a mean + * and a JS-computed median (see queryFirstCrLatencies for why the median is + * computed here instead of in SQL). + */ +function firstCrByMonth(rows) { + const byMonth = new Map(); + rows.forEach(function (row) { + if (!byMonth.has(row.month)) { + byMonth.set(row.month, []); + } + byMonth.get(row.month).push(Number(row.latency_seconds) / 3600); + }); + + return [...byMonth.keys()].sort().map(function (month) { + const hours = byMonth.get(month); + const avgHours = + hours.reduce(function (a, b) { + return a + b; + }, 0) / hours.length; + return { + month, + avgHours: round1(avgHours), + medianHours: round1(median(hours)), + sampled: hours.length, + }; + }); +} + +function median(xs) { + if (!xs.length) return 0; + const sorted = [...xs].sort(function (a, b) { + return a - b; + }); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +function round1(n) { + return Math.round(n * 10) / 10; +} + +// mysql2 returns DATE columns as JS Date objects (in the connection's local +// time) — format as a plain YYYY-MM-DD so the wire shape is timezone-free. +function formatDate(date) { + const d = new Date(date); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return y + '-' + m + '-' + day; +} + +// MySQL's YEARWEEK(..., 3) (ISO mode) returns e.g. 202629 for ISO week 29 of +// 2026; split it back into "2026-W29". +function formatIsoWeek(yearweek) { + const n = Number(yearweek); + const year = Math.floor(n / 100); + const week = n % 100; + return year + '-W' + String(week).padStart(2, '0'); +} + +function loadHistory() { + return Promise.all([ + queryMonthly(), + queryMergeAgeByDay(), + queryFirstCrLatencies(), + queryDurationByWeek(), + ]).then(function (results) { + const monthly = results[0]; + const mergeAgeByDay = results[1]; + const firstCrRows = results[2]; + const durationByWeek = results[3]; + + return { + monthly: monthly, + mergeAgeByDay: mergeAgeByDay, + firstCrByMonth: firstCrByMonth(firstCrRows), + durationByWeek: durationByWeek, + }; + }); +} + +// Named exports below are the pure reducers (grouping/median/date-formatting) +// factored out so they're independently testable without a live DB connection +// (mirrors lib/refresh.js exporting processIssueItem/processPullItem for the +// same reason). +export { firstCrByMonth, median, formatDate, formatIsoWeek, round1 }; + +export default { + /** + * GET /stats-history — aggregate PR history (months/weeks/days, not the + * live board) for the frontend-v2 Stats lens trend cards. Auth-gated + * exactly like /token (see lib/authentication.js setupRoutes). Read-only, + * memoized for CACHE_TTL_MS since the underlying data moves slowly. + */ + getHistory: function (req, res) { + if (isFresh(cache, CACHE_TTL_MS)) { + statsDebug('getHistory: serving cached response'); + return res.json(cache.body); + } + + respondOrError( + res, + loadHistory().then(function (body) { + cache = { at: Date.now(), body: body }; + return body; + }), + 'stats-history query failed' + ); + }, +}; diff --git a/controllers/user-names.js b/controllers/user-names.js new file mode 100644 index 00000000..32c67101 --- /dev/null +++ b/controllers/user-names.js @@ -0,0 +1,145 @@ +import git from '../lib/git-manager.js'; +import debug from '../lib/debug.js'; +import { isFresh as isCacheFresh } from '../lib/ttl-cache.js'; +import { respondOrError } from '../lib/controller-utils.js'; + +const userNamesDebug = debug('pulldasher:user-names'); + +// This moves slowly (a login's display name essentially never changes) and +// every open pull re-requests the same handful of authors/reviewers, so cache +// per-login for a day, shared across every request (mirrors controllers/stats.js's +// CACHE_TTL_MS memo). A 404/error caches to `null` too (negative cache) -- +// otherwise a bot or deleted account would hit GitHub on every single request. +export const CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +// GitHub logins are already short; capping the batch keeps one request from +// fanning out into an unbounded number of concurrent Octokit calls. +export const MAX_LOGINS = 100; + +const CONCURRENCY = 5; + +// GitHub logins are alphanumeric + hyphens; app-created logins add a +// `[bot]` suffix (e.g. `dependabot[bot]`). Anything else isn't a login this +// endpoint will ever legitimately be asked about. +export const LOGIN_RE = /^[A-Za-z0-9-[\]]+$/; + +export function isValidLogin(login) { + return typeof login === 'string' && LOGIN_RE.test(login); +} + +/** + * Splits "alice, bob,,alice" into ["alice", "bob"] -- trimmed, blanks + * dropped, de-duplicated (first occurrence wins), order preserved. Does not + * filter by isValidLogin -- callers decide what to do with the ones that + * fail sanitization. + */ +export function parseLoginsParam(raw) { + if (!raw) return []; + const seen = new Set(); + for (const part of raw.split(',')) { + const login = part.trim(); + if (login) seen.add(login); + } + return [...seen]; +} + +/** + * Whether a cache entry ({ name, at }) is still within CACHE_TTL_MS of `now`. + * A missing entry is never fresh. Thin wrapper over the shared ttl-cache + * check, keeping this module's own CACHE_TTL_MS baked in for callers here and + * in tests. + */ +export function isFresh(entry, now = Date.now()) { + return isCacheFresh(entry, CACHE_TTL_MS, now); +} + +/** + * Resolves `logins` (already sanitized, deduplicated) to display names. + * Reuses any entry in `cache` still fresh under CACHE_TTL_MS; fetches the + * rest through `fetcher` (real callers pass one backed by Octokit; tests pass + * a stub) with at most `concurrency` requests in flight at once. GitHub's + * rate limit is already handled by the Octokit throttling plugin (see + * lib/git-manager.js) -- this concurrency cap only exists so a 100-login + * request doesn't open 100 sockets at once, not as a second rate limiter. + * + * `cache` is a login -> { name, at } Map, mutated in place -- including + * negative entries, so a login that 404s or errors is remembered as `null` + * for CACHE_TTL_MS rather than retried on every request. Production callers + * share one Map across requests (module-level, below); tests pass a fresh one. + */ +export async function resolveNames( + logins, + { cache, fetcher, concurrency = CONCURRENCY, now = Date.now() } +) { + const toFetch = logins.filter(login => !isFresh(cache.get(login), now)); + const queue = [...toFetch]; + + async function worker() { + let login; + while ((login = queue.shift()) !== undefined) { + const name = await fetcher(login); + cache.set(login, { name, at: Date.now() }); + } + } + + await Promise.all(Array.from({ length: Math.min(concurrency, toFetch.length) }, worker)); + + const names = {}; + for (const login of logins) { + names[login] = cache.get(login)?.name ?? null; + } + return names; +} + +/** Real fetcher: one login's `name` field from GitHub (may legitimately be + * null -- lots of users don't set one). Never rejects -- a 404 (deleted/renamed + * account) or any other error just resolves to null, same as "no name set". */ +function githubFetcher(login) { + return git.github.users + .getByUsername({ username: login }) + .then(res => res.data.name ?? null) + .catch(err => { + userNamesDebug( + 'Could not fetch name for %s: %s', + login, + (err && err.status) || (err && err.message) || err + ); + return null; + }); +} + +// Shared across every request for the life of the process. +const cache = new Map(); + +export default { + /** + * GET /user-names?logins=a,b,c -- resolve GitHub logins to human display + * names, for the frontend's people-facing UI (PRs only carry logins on + * the wire). Auth-gated exactly like /token (see lib/authentication.js + * setupRoutes). Every requested login gets an entry in the response, even + * ones that fail sanitization or that GitHub has no name for -- those are + * `null`, never dropped from the response shape. + */ + getNames: function (req, res) { + const raw = typeof req.query.logins === 'string' ? req.query.logins : ''; + const requested = parseLoginsParam(raw); + + if (requested.length > MAX_LOGINS) { + return res.status(400).json({ error: `logins capped at ${MAX_LOGINS} per request` }); + } + + const validLogins = requested.filter(isValidLogin); + + respondOrError( + res, + resolveNames(validLogins, { cache, fetcher: githubFetcher }).then(names => { + const body = {}; + for (const login of requested) { + body[login] = names[login] ?? null; + } + return { names: body }; + }), + 'user-names query failed' + ); + }, +}; diff --git a/frontend-v2/README.md b/frontend-v2/README.md new file mode 100644 index 00000000..08d6d16c --- /dev/null +++ b/frontend-v2/README.md @@ -0,0 +1,156 @@ +# Pulldasher frontend v2 + +A reviewer-centric frontend that answers "what should I act on next?" instead +of rendering seven flat columns. It is the only Pulldasher frontend — the +server serves it at `/`, fed by the socket protocol (`initialize` + +`pullChange`), so nothing about the backend or DB changes. (The legacy v1 +board it replaced lives on only as the Classic lens, a faithful port of its +seven-column layout.) + +## Ideas + +- **One pull, one status.** `src/model/status.ts` derives a single mutually + exclusive status per pull (draft > dev_block > ci_red > needs_recr > + needs_cr > needs_qa > deploy_block > ci_pending > unmergeable > ready) + instead of v1's overlapping column predicates. The three flavors of + "blocked" are separate statuses because they mean opposite things: a dev + block is the author's move, a deploy hold is done-but-don't-ship, and + unmergeable is a rebase. +- **Yours to do.** The Review tab opens with every action that's yours — + re-stamps you owe (CR and QA), your merge buttons, your CI fixes — + regardless of who authored the pull. `src/model/actions.ts` is the single + source for "whose move is it", shared with My work. +- **One item, one order, everywhere.** Every lens renders the same card: + avatar and full-width title on top, one meta line below (status, repo, + context, flags) ending in a fixed metric rail — review effort, then CR and + QA sign-off pips, then age — right-anchored so the rail reads as vertical + columns down any board. Giving the title its own line is what lets a long + title wrap cleanly and what lets the whole thing reflow to a phone; + Classic keeps its columns, but the card inside them is the same one the + lanes use. The whole card is a click target (the title link stretches over + it), so a click anywhere opens the PR. +- **The re-stamp lane.** Inactive CR/QA signatures (invalidated by a push) + are already on the wire; a pull that was reviewed and fixed but lacks a + fresh stamp is the cheapest review on the board, so it leads. Sign-off is a + pip meter — one square per required stamp: filled green is a live stamp, + amber is a stamp a push invalidated (a re-stamp is owed, the board's most + actionable state), hollow is still needed. A dotted underline marks a slot + you stamped; clicking the pips lists who signed and when. No check glyph, no + slash — the fill is the whole vocabulary. +- **Review effort, always shown.** A five-segment meter (light to heavy, + color-ramped) rides the rightmost rail of every row, so "can I fit this in + the time I have" reads without opening the diff. A cheap prior from diff + size; humans override by reading. The concrete `+added −deleted` sits beside + the repo for when the gauge isn't precise enough. +- **A queue that ranks the right thing.** `src/model/sort.ts` scores by + weight, then boosts pulls one stamp from done and credits age, so an old M + outranks a fresh S before the starvation cliff. Thresholds are tuned to + the shop's measured cadence (median first review under 2 hours, p90 ~4 + days): ages show hours under a day, heat amber past 4 days and red past + 10, with both clocks in the tooltip. "Iterating" keys on the push clock, + not updated_at, so a reviewer's comment can't sink a pull down the queue. +- **Five lenses, one filter.** Review / My work / People (person or team + drill-down) / Classic / Stats, over the same pool. The whole view — lens, + query, scope, toggles — lives in the URL hash: any board is pasteable and a + bookmark is a saved view. The text filter understands `#number`, `label:x`, + `status:x`, `older:5`, `repo:x`, and `author:x`. +- **The Stats lens.** The board's shape and its review economics: the open + breakdown as a stacked bar, CR and QA leaderboards (distinct PRs signed off, + over the current pool), who's waiting longest for CR by author, and time to + merge by diff-size class over the closed window. It moves with the scope, so + scoping to a team makes these that team's numbers. `src/model/stats.ts` is + the aggregation layer; the cards are pure presentational. +- **Settings.** A cog opens a right-side panel for the knobs that are personal + taste, not team policy or model math: theme (system/light/dark), row + density, default view, the age-color thresholds (amber/red days, display + only), the glance guard (how many attended seconds count as a look) plus a + one-click "mark everything as seen", your drafts and cryo defaults, and the + Repo Manager (mute/unmute repos, unhide org-hidden ones). Persisted + per-browser in `src/settings.ts`. Org-level repo hides come from the + server's `hideByDefault` repo spec — the read-only baseline you override. +- **One visibility model: muted vs in-scope, override for now.** Scope + (include) and mute (exclude) aren't rival mechanisms — scope is "which + slice am I focused on", mute is "repos I never want to see", and a session + act (a scope, a `repo:` term, a reveal) overrides a mute for right now. + Three parties vote in a fixed order — org baseline (`hideByDefault` + cryo), + your durable prefs (muted repos, drafts default), then the session (URL) — + with session-explicit beating a durable mute. It all lives behind one + **Filters** control whose trigger is the active-filter summary (the standing + answer to "what's hidden right now"): tabs for Repos (include checkbox + + mute icon per row, muted/org-hidden collapsed with a reveal), People (teams + + authors), and Drafts (Mine/All — your own drafts always show). The durable + editor is the Settings **Repo Manager** (muted / org-hidden / on-your-board). + `src/model/visibility.ts` decides a repo's baseline state; the old separate + Scope popover and eye/Hidden selector folded into this. Session reveal still + rides the URL as `#show=cryo,someRepo`; `#drafts=all` overrides your default. +- **Classic is v1, faithfully.** The same six overlapping columns + (CI Blocked / Deploy Blocked / Ready / Dev Block / CR / QA), predicates + and sorts ported line-for-line from v1, for anyone whose muscle memory + lives there. +- **Changed since your last look.** A last-seen marker in localStorage, + stamped when you leave (pagehide) and only after ~45 visible seconds, so a + glance at another tab can't erase the weekend's delta. Solid dot = new PR, + ring = updated; opening a PR clears its dot. What changed is a **lane** in + Review (newest first), not a bar toggle — it collects the delta at the top + the way "Yours to do" collects your actions, rather than filtering the board. + A slim banner still counts the PRs merged or closed while you were away. +- **Desktop notifications.** Opt-in from Settings: a browser notification for + every real transition that lands on you — your PR going mergeable, breaking + CI, getting feedback or needing a rebase, and re-CRs/re-QAs falling to you + (`model/actions.ts` `alertMove`). Each alert is clickable (focuses the tab and + opens the PR), replaces any older one for the same PR (`tag`), and carries an + optional two-note chime whose AudioContext is unlocked by the toggle's own + click so it isn't muted by autoplay policy when the tab is backgrounded. It + watches the whole board (not the current filter) and fires only while the tab + is unfocused — when you're looking, the board already shows the change. The + first payload after load just primes the baseline, so it never alerts for the + backlog already there, and a "send a test" button confirms the setup. + `src/notifications.ts`. +- **Keyboard:** `/` filter, `j`/`k` walk rows, `Enter` opens, `c` copies the + focused row's branch. + +## Running it + +```bash +cd frontend-v2 +npm install +npm run dev # against a local backend on :3000 (proxies /token + socket) +npm run dev:dummy # no backend: v1's dummy-pulls.json fixture +npm run build # type-check + bundle to frontend-v2/dist (served at /) +npm test # vitest: status, sort, query, and legacy-URL unit tests +``` + +## Site config + +Deployment config (`bots`, `weightLabels`) lives in the backend's `config.js` +— the one source — and rides to the board in the socket `initialize` payload; +there's no separate `config.json` fetch. See the repo-root `config.example.js`. + +- `bots` — machine-account logins to fold into the bot-PRs group. Accounts + with GitHub's `[bot]` suffix are detected without config. +- `weightLabels` — label title → review-weight bucket (`XS`|`S`|`M`|`L`|`XL`); + a PR carrying one takes that weight over the diff-size heuristic. The same + values feed the `/api/v1` classification. + +## v1 bookmark compatibility + +v1 saved its settings in the query string, and years of bookmarks encode +them. A URL with any v1 param (`repo`, `author`, `cryo`, `drafts`, +`external_block`, `personal`, `closed`, or the `ci`/`dep`/`ready`/`dev`/ +`cr`/`qa` column-collapse flags) opens the Classic lens configured the same +way, v1 defaults included (other people's drafts hidden unless `drafts=1`, +`closed=1` shows the Recently Closed column). A dismissible chip names what +the bookmark applied. `src/legacy.ts` owns the translation. (v2's own board +lens `#lens=board` merged into Classic; old links redirect.) + +## Known gaps (backend additions v2 could use) + +- **Comment/review activity isn't on the wire** — cues like "X commented 2h + ago" need the server to include recent comment metadata in `toObject()`. +- **Teams over the handshake** instead of a static file (see above). +- **`changed_files`** is stored in the DB but not sent; the review-effort + meter falls back to size-only until it is, and the Stats merge-time chart + drops PRs missing `additions`/`deletions`. +- **Stats are windowed, not historical.** Leaderboards and merge-time only see + the open pool plus the server's 14-day closed window, so they read "lately", + not "all time". Career totals would need the server to expose more history. diff --git a/frontend-v2/index.html b/frontend-v2/index.html new file mode 100644 index 00000000..0a74b9ff --- /dev/null +++ b/frontend-v2/index.html @@ -0,0 +1,13 @@ + + + + + + + Pulldasher + + +
+ + + diff --git a/frontend-v2/package-lock.json b/frontend-v2/package-lock.json new file mode 100644 index 00000000..b1e9c032 --- /dev/null +++ b/frontend-v2/package-lock.json @@ -0,0 +1,4486 @@ +{ + "name": "pulldasher-frontend-v2", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pulldasher-frontend-v2", + "version": "0.0.1", + "dependencies": { + "dompurify": "^3.4.12", + "lucide-react": "^1.25.0", + "marked": "^18.0.7", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "socket.io-client": "^4.8.3" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^29.1.1", + "tailwindcss": "^4.1.0", + "typescript": "^5.7.2", + "vite": "^6.0.0", + "vitest": "^2.1.8" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dompurify": { + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.392", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.392.tgz", + "integrity": "sha512-1yQq3VQCZRwsnYc67Oc+1fge6Lwtn0hzi6zmEVkB61Zx21kTbwJAW4dFLadl5Rc1tKhG/kSpYXnfiAhu0f0a1g==", + "dev": true, + "license": "ISC" + }, + "node_modules/engine.io-client": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", + "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz", + "integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "18.0.7", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.7.tgz", + "integrity": "sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend-v2/package.json b/frontend-v2/package.json new file mode 100644 index 00000000..060c149a --- /dev/null +++ b/frontend-v2/package.json @@ -0,0 +1,33 @@ +{ + "name": "pulldasher-frontend-v2", + "private": true, + "version": "0.0.1", + "description": "Reviewer-centric Pulldasher frontend (React 18 + Vite + Tailwind v4)", + "type": "module", + "scripts": { + "dev": "vite", + "dev:dummy": "VITE_DUMMY=1 VITE_DUMMY_USER=danielbeardsley vite", + "build": "tsc --noEmit && vite build", + "test": "vitest run", + "preview": "vite preview" + }, + "dependencies": { + "dompurify": "^3.4.12", + "lucide-react": "^1.25.0", + "marked": "^18.0.7", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "socket.io-client": "^4.8.3" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^29.1.1", + "tailwindcss": "^4.1.0", + "typescript": "^5.7.2", + "vite": "^6.0.0", + "vitest": "^2.1.8" + } +} diff --git a/frontend-v2/public/favicon.svg b/frontend-v2/public/favicon.svg new file mode 100644 index 00000000..714ca888 --- /dev/null +++ b/frontend-v2/public/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend-v2/src/app.tsx b/frontend-v2/src/app.tsx new file mode 100644 index 00000000..6b22dab5 --- /dev/null +++ b/frontend-v2/src/app.tsx @@ -0,0 +1,1088 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { ago, closedEpoch, n, pullKey, shortRepo } from '../../shared/format'; +import type { ActionStateKey } from './model/actions'; +import { actionState } from './model/actions'; +import { LENS_LABELS, type Lens } from './lens'; +import type { DerivedPull } from '../../shared/model/status'; +import { matchesWeightFilter } from '../../shared/model/status'; +import { buildParentLookup } from './model/stack'; +import { buildReviewerPools, turnFor } from './model/rotation'; +import { shipRelevance, shippedToast } from './model/shipped'; +import type { Toast } from './model/toast'; +import { claimReview, isSnoozed, usePulldasher } from './store'; +import { primeScope, useScope } from './prefs'; +import { useBoardHotkeys, useHeaderHeightVar } from './hooks'; +import { ageRotDays, getSettings, type Settings as SettingsShape, useSettings } from './settings'; +import { useNotifications } from './notifications'; +import { ToastStack, useToasts } from './toasts'; +import { matchesQuery } from './model/query'; +import { requestNames, useNames } from './model/names'; +import { CRYO_KEY, isBotLogin, personHidden, repoHidden } from '../../shared/model/visibility'; +import { reviewRequestedFrom } from './model/reviewers'; +import { CornerBadge, textInputClass } from './components/bits'; +import { foldDomId, openFold } from './components/Lane'; +import { Legend } from './components/Legend'; +import { LensMenu } from './components/LensMenu'; +import { Logo } from './components/Logo'; +import { Wordmark } from './components/Wordmark'; +import { NotificationPanel } from './components/NotificationPanel'; +import { RepoFilter } from './components/filters/RepoFilter'; +import { PeopleFilter } from './components/filters/PeopleFilter'; +import { WeightFilter } from './components/filters/WeightFilter'; +import { StateFilter } from './components/filters/StateFilter'; +import { HiddenPanel } from './components/filters/HiddenPanel'; +import { hasActiveFilters } from './components/filters/shared'; +import { + applySavedFilter, + describeHash, + matchesView, + useAllSavedFilters, +} from './model/savedFilters'; +import { SavedFiltersInput, SavedFiltersMenu } from './components/SavedFiltersPanel'; +import type { RowOptions } from './components/Row'; +import { Review } from './views/Review'; +import { MyWork } from './views/MyWork'; +import { Team } from './views/Team'; +import { Classic } from './views/Classic'; +import { Ci } from './views/Ci'; +import { Stats } from './views/Stats'; +import { Settings } from './components/Settings'; + +export type { Lens }; + +const LENSES: Lens[] = ['review', 'mine', 'team', 'classic', 'ci', 'stats']; + +/** every actionState bucket, for validating the `state=` hash param against */ +const ACTION_STATE_KEYS: ActionStateKey[] = [ + 'restamp', + 'review', + 'qa', + 'mine', + 'blocked', + 'waiting', +]; + +/** + * The whole view lives in the hash — lens, drill-downs, query, scope, + * toggles — so any board is pasteable and a bookmark is a saved view + * (v1's superpower, restored). Defaults are omitted, so a plain /v2/ URL + * stays plain. + */ +interface HashState { + lens: Lens; + q: string; + repos: string[]; + authors: string[]; + /** logins scoped OUT (the team chips' "excluding" state) */ + notAuthors: string[]; + /** review-effort classes to narrow to: 'xs'..'xl' or 'unknown' */ + weight: string[]; + /** actionState buckets to narrow to (model/actions.ts) */ + state: ActionStateKey[]; + /** master reveal: show every off-by-default PR */ + hidden: boolean; + /** individually revealed hidden groups: repo names and the cryo sentinel */ + reveal: string[]; + /** session override of the drafts default (null = use the durable default) */ + drafts: 'mine' | 'all' | null; +} + +/** + * The lens a bare hash (no `lens=` param) resolves to — the single source of + * truth both readHash's fallback and buildHash's omission rule must agree on. + * buildHash used to compare against a hardcoded 'review' while this fallback + * tracked the user's configured default: with any other default, clicking + * Review wrote a lens-less hash, the hashchange listener re-read it via this + * same fallback, and the board snapped back to the configured default. + */ +function defaultLensFallback(): Lens { + const preferred = getSettings().defaultLens as Lens; + return LENSES.includes(preferred) ? preferred : ('review' as Lens); +} + +function readHash(): HashState { + const p = new URLSearchParams(location.hash.slice(1)); + const lens = p.get('lens') as Lens | null; + // a bare URL (no lens param) opens the user's configured default view + const fallback = defaultLensFallback(); + return { + lens: lens && LENSES.includes(lens) ? lens : fallback, + q: p.get('q') ?? '', + repos: p.get('repos')?.split(',').filter(Boolean) ?? [], + authors: p.get('authors')?.split(',').filter(Boolean) ?? [], + notAuthors: p.get('xauthors')?.split(',').filter(Boolean) ?? [], + weight: p.get('weight')?.split(',').filter(Boolean) ?? [], + state: (p.get('state')?.split(',').filter(Boolean) ?? []).filter((s): s is ActionStateKey => + ACTION_STATE_KEYS.includes(s as ActionStateKey) + ), + hidden: p.get('hidden') === '1', + reveal: p.get('show')?.split(',').filter(Boolean) ?? [], + drafts: p.get('drafts') === 'all' ? 'all' : p.get('drafts') === 'mine' ? 'mine' : null, + }; +} + +function buildHash(s: HashState): string { + const p = new URLSearchParams(); + if (s.lens !== defaultLensFallback()) p.set('lens', s.lens); + if (s.q) p.set('q', s.q); + if (s.repos.length) p.set('repos', s.repos.join(',')); + if (s.authors.length) p.set('authors', s.authors.join(',')); + if (s.notAuthors.length) p.set('xauthors', s.notAuthors.join(',')); + if (s.weight.length) p.set('weight', s.weight.join(',')); + if (s.state.length) p.set('state', s.state.join(',')); + if (s.hidden) p.set('hidden', '1'); + if (s.reveal.length) p.set('show', s.reveal.join(',')); + if (s.drafts) p.set('drafts', s.drafts); + return p.toString(); +} + +// A shared link's scope applies for the session without touching the +// visitor's saved scope; their own edits still persist as usual. +const urlState = readHash(); +if (urlState.repos.length || urlState.authors.length || urlState.notAuthors.length) { + primeScope({ + repos: urlState.repos, + authors: urlState.authors, + notAuthors: urlState.notAuthors, + }); +} + +/** Full-width notice under the header: bad = red alert, warn = amber, brand = informational. */ +function Banner({ + tone, + onClick, + ariaLabel, + children, +}: { + tone: 'bad' | 'warn' | 'brand'; + /** present only when the banner has somewhere useful to send the click — + * renders it as a real button instead of a static strip */ + onClick?: () => void; + ariaLabel?: string; + children: ReactNode; +}) { + const inner: Record = { + bad: 'border-bad bg-surface text-bad', + warn: 'border-warn bg-surface', + brand: 'notice-inner border-brand bg-brand-50 text-brand-700', + }; + const surface = `flex w-full items-center gap-2 rounded-lg border px-3 py-[7px] text-left ${inner[tone]} ${ + onClick ? 'pressable hover:bg-brand-100' : '' + }`; + return ( +
+ {onClick ? ( + + ) : ( +
+ {children} +
+ )} +
+ ); +} + +/** + * The hidden-repo/hidden-person check boardHidden (open DerivedPulls) and + * scopedClosed (closed PullData, below) both need — same two rules, just a + * different shape of pull to read them off. Takes primitives rather than + * either pull type so the two call sites can each do their own field access + * (DerivedPull nests repo/login under `.data`; PullData doesn't) and their + * own bot check (isBot(p) vs isBotLogin(login, extraBots)) before calling in. + * Cryo and the drafts rule are NOT here: scopedClosed has no cryo/draft state + * of its own to hide by, so only boardHidden layers those on top of this. + */ +function isHiddenFor( + pull: { login: string; repo: string; isBot: boolean }, + settings: Pick, + me: string, + revealedRepo: (repo: string) => boolean, + revealedAuthor: (login: string) => boolean +): boolean { + const hiddenRepo = repoHidden(pull.repo, settings.repoPrefs) && !revealedRepo(pull.repo); + const hiddenPerson = + pull.login !== me && + !pull.isBot && + personHidden(pull.login, settings.hiddenPeople) && + !revealedAuthor(pull.login); + return hiddenRepo || hiddenPerson; +} + +export function App() { + const { + pulls, + extraBots, + closed, + me, + connection, + initialized, + authFailed, + lastPayloadAt, + lastSeen, + refreshProgress, + snoozed, + } = usePulldasher(); + // per-repo reviewer pools and whose turn each starved, unclaimed pull is — + // computed ONCE over the whole board and shared by the rows (rowOpts), + // desktop notifications, and the cheers evaluator, so the three surfaces + // can never disagree about the rotation (and never re-derive it in + // parallel; buildReviewerPools walks every pull's signatures) + const pools = useMemo(() => buildReviewerPools(pulls), [pulls]); + // Publish the widest CR/QA required counts on the board so every row's pip + // slot pads to them (styles.css .pd-pip-slot-*) and the marks line up in a + // column. Over ALL open pulls, not the filtered view: simpler, and the + // column just tracks what's open (a PR bumped to cr_req 3 widens every slot + // until it merges). Floor of 1 so a slot always holds at least one mark. + const maxCrReq = useMemo( + () => pulls.reduce((m, p) => Math.max(m, Number(p.data.status.cr_req) || 0), 1), + [pulls] + ); + const maxQaReq = useMemo( + () => pulls.reduce((m, p) => Math.max(m, Number(p.data.status.qa_req) || 0), 1), + [pulls] + ); + useEffect(() => { + const root = document.documentElement.style; + root.setProperty('--cr-slots', String(maxCrReq)); + root.setProperty('--qa-slots', String(maxQaReq)); + }, [maxCrReq, maxQaReq]); + const turns = useMemo(() => { + const m = new Map(); + for (const p of pulls) { + const who = turnFor(p, pools, pulls); + if (who) m.set(pullKey(p.data), who); + } + return m; + }, [pulls, pools]); + // resolve every author on the board to a human display name (batched, + // cached — model/names.ts): the person hover-cards, the people pickers, + // and name-aware search all read from this one map + const names = useNames(); + const [scope, setScope] = useScope(); + const [lens, setLens] = useState(() => readHash().lens); + const [query, setQuery] = useState(() => urlState.q); + // narrows the board to one or more review-effort classes ('xs'..'xl', + // 'unknown'); empty = no filter + const [weightSel, setWeightSel] = useState(() => urlState.weight); + // narrows the board to one or more actionState buckets; empty = no filter + const [stateSel, setStateSel] = useState(() => urlState.state); + // "hidden" is two off-by-default groups (Cryogenic-Storage PRs, quiet + // repos). showAll reveals both; reveal names individual groups to show. + const [showAll, setShowAll] = useState(() => urlState.hidden); + const [reveal, setReveal] = useState(() => urlState.reveal); + const toggleReveal = useCallback( + (key: string) => + setReveal(cur => (cur.includes(key) ? cur.filter(k => k !== key) : [...cur, key])), + [] + ); + // drafts is a session override of the durable default; null-in-URL uses it + const [draftsMode, setDraftsMode] = useState<'mine' | 'all'>( + () => urlState.drafts ?? getSettings().draftsMode + ); + // login-level twin of isBot, for row-level consumers (the avatar's square + // bot tile) that hold a login rather than a DerivedPull + const isBotAuthor = useCallback((login: string) => isBotLogin(login, extraBots), [extraBots]); + const isBot = useCallback((p: DerivedPull) => isBotAuthor(p.data.user.login), [isBotAuthor]); + // theme, density, default view, age colors, glance guard — all live in + // settings now (the cog panel), persisted per-browser + const settings = useSettings(); + // a stable primitive (not a fresh array/object each render) so adding it to + // the requestNames effect's deps below re-runs the effect when a teammate + // or hidden person is added, without looping on unrelated settings writes + const settingsLogins = useMemo( + () => [...settings.teams.flatMap(t => t.members), ...settings.hiddenPeople].join(','), + [settings.teams, settings.hiddenPeople] + ); + useEffect(() => { + requestNames([ + ...pulls.map(p => p.data.user.login), + ...closed.map(p => p.user.login), + ...settingsLogins.split(',').filter(Boolean), + ]); + }, [pulls, closed, settingsLogins]); + const [systemDark, setSystemDark] = useState( + () => matchMedia('(prefers-color-scheme: dark)').matches + ); + const dark = settings.theme === 'dark' || (settings.theme === 'system' && systemDark); + const searchRef = useRef(null); + + // The whole session view as one object — what buildHash writes to the URL + // AND what "Save current filter…" would capture right now must never + // disagree, so both the write-effect below and currentHash (further down) + // read this one computed value instead of each re-building the same 10 + // fields. + const hashState: HashState = useMemo( + () => ({ + lens, + q: query, + repos: scope.repos, + authors: scope.authors, + notAuthors: scope.notAuthors, + weight: weightSel, + state: stateSel, + hidden: showAll, + reveal, + drafts: draftsMode !== settings.draftsMode ? draftsMode : null, + }), + [lens, query, scope, weightSel, stateSel, showAll, reveal, draftsMode, settings.draftsMode] + ); + // View changes (lens switches) earn a history entry so the back button + // navigates between boards; filter tweaks replace in place so typing a + // query doesn't bury history under keystrokes. + const prevView = useRef({ lens }); + useEffect(() => { + const next = buildHash(hashState); + if (next === location.hash.slice(1)) return; + const prev = prevView.current; + prevView.current = { lens }; + if (prev.lens !== lens) { + if (next) { + // fires hashchange; the listener below re-reads idempotently + location.hash = next; + } else { + // same empty-hash fallback as the branch below: assigning + // location.hash = '' renders as a stray trailing '#'. pushState + // (not replaceState) keeps the history entry a lens change earns. + history.pushState(null, '', location.pathname + location.search); + } + } else { + history.replaceState(null, '', next ? `#${next}` : location.pathname + location.search); + } + // buildHash's omission rule reads settings.defaultLens too (via + // defaultLensFallback) even though it's not one of hashState's own + // fields, so it stays a separate dependency here. + }, [hashState, lens, settings.defaultLens]); + useEffect(() => { + const onHash = () => { + const h = readHash(); + setLens(h.lens); + setQuery(h.q); + setWeightSel(h.weight); + setStateSel(h.state); + setShowAll(h.hidden); + setReveal(h.reveal); + setDraftsMode(h.drafts ?? getSettings().draftsMode); + // the hash is the whole view, scope included: applying a saved + // search (or walking history) must land its repos/authors too. + // Primed, not persisted — same rule as opening a shared link. + primeScope({ repos: h.repos, authors: h.authors, notAuthors: h.notAuthors }); + }; + window.addEventListener('hashchange', onHash); + return () => window.removeEventListener('hashchange', onHash); + }, []); + // the entrance settle runs once per visit, not on every tab switch + const [entrance, setEntrance] = useState(true); + useEffect(() => { + const t = setTimeout(() => setEntrance(false), 700); + return () => clearTimeout(t); + }, []); + // lane/section headers stick just below the app header; see hooks.ts's + // useHeaderHeightVar for why (its height varies with the toolbar wrap, and + // getBoundingClientRect over offsetHeight avoids a hairline gap at + // non-100% zoom). + const headerRef = useRef(null); + useHeaderHeightVar(headerRef); + useEffect(() => { + document.documentElement.classList.toggle('dark', dark); + }, [dark]); + useEffect(() => { + document.documentElement.dataset.density = settings.density; + }, [settings.density]); + // follow the OS live (only visible while theme is 'system') + useEffect(() => { + const mq = matchMedia('(prefers-color-scheme: dark)'); + const follow = () => setSystemDark(mq.matches); + mq.addEventListener('change', follow); + return () => mq.removeEventListener('change', follow); + }, []); + // the board's j/k/c/"/" shortcuts — see hooks.ts's useBoardHotkeys + useBoardHotkeys(searchRef); + + // scope/query applied, but NOT the changed-only toggle: the changed count + // must describe the pool the toggle would narrow, or the banner promises + // rows the click doesn't deliver + // repos named in the query (repo:x) reveal a hidden repo for this session — + // an explicit filter is an explicit "I want it now" + const queryRepos = useMemo( + () => (query ? [...query.matchAll(/repo:(\S+)/gi)].map(m => m[1].toLowerCase()) : []), + [query] + ); + const revealedRepo = useCallback( + (repo: string) => + reveal.includes(repo) || + scope.repos.includes(repo) || + queryRepos.some(q => shortRepo(repo).toLowerCase().includes(q)), + [reveal, scope.repos, queryRepos] + ); + // same idea as queryRepos/revealedRepo, but people have no reveal= list of + // their own — muting is a plain two-state toggle, so a scope pick or an + // author: query term is the whole reveal story + const queryAuthors = useMemo( + () => (query ? [...query.matchAll(/author:(\S+)/gi)].map(m => m[1].toLowerCase()) : []), + [query] + ); + const revealedAuthor = useCallback( + (login: string) => + scope.authors.includes(login) || queryAuthors.some(q => login.toLowerCase().includes(q)), + [scope.authors, queryAuthors] + ); + + // The one is-this-pull-off-the-board predicate: hidden repos, hidden + // people, cryo, snoozes, and the drafts rule stay off unless a session + // act reveals them — an explicit reveal, a scope, a repo:/author: query + // term, or the master "show everything". Never hides your own pulls; bots + // hide only when you turn on Ignore bot PRs (they otherwise have their own + // fold), and a person can't hide themselves. Shared by the filter pipeline and the + // hidden-PR ledger's counts so the ledger's number can never disagree + // with what the board actually withholds. + const boardHidden = useCallback( + (p: DerivedPull) => { + if (showAll) return false; + const hidden = isHiddenFor( + { login: p.data.user.login, repo: p.data.repo, isBot: isBot(p) }, + settings, + me, + revealedRepo, + revealedAuthor + ); + const cryoHidden = p.cryo && !settings.showCryo && !reveal.includes(CRYO_KEY); + // drafts: 'mine' keeps other people's drafts quiet on the general + // board, but not when you've deliberately looked (scoped/`author:`d + // that person) or when GitHub explicitly requested your review — a + // review-requested draft was vanishing even from the reviewer it was + // requested from, the reported bug. Your own drafts always show. + const draftHidden = + draftsMode === 'mine' && + p.data.draft && + p.data.user.login !== me && + !revealedAuthor(p.data.user.login) && + !reviewRequestedFrom(p, me); + // snoozes are NOT here: a snooze only quiets the Review lens (its + // "not today" gesture), so the Review view applies it itself and + // every other lens still shows the pull + // "Ignore bot PRs": a saved preference that keeps machine authors off + // the board entirely (they otherwise live in their own low-priority + // fold). "Show everything" still reveals them via the showAll early + // return above. + const botHidden = settings.hideBots && isBot(p); + return hidden || cryoHidden || draftHidden || botHidden; + }, + [ + showAll, + reveal, + revealedRepo, + revealedAuthor, + me, + isBot, + settings.repoPrefs, + settings.showCryo, + settings.hiddenPeople, + settings.hideBots, + draftsMode, + ] + ); + + // Nudges/notifications honor the STANDING hidden-repo/hidden-person + // settings only — never the current view's scope/query/weight/state + // filters, and never a session reveal — so a hidden-repo (or hidden- + // person) PR can never surface a cheer/nudge toast or desktop + // notification. Reuses the board's own isHiddenFor with reveals pinned + // to false, rather than a second copy of the hide logic. + const nudgeablePulls = useMemo( + () => + pulls.filter( + p => + !isHiddenFor( + { login: p.data.user.login, repo: p.data.repo, isBot: isBot(p) }, + settings, + me, + () => false, + () => false + ) + ), + [pulls, settings, me, isBot] + ); + // desktop notifications watch the whole board (minus the standing hidden + // settings above), not the current filter + useNotifications(nudgeablePulls, me, turns, initialized); + + // every existing scope/query pass, but not yet the Weight/State filters — + // WeightFilter's live per-option counts read this pool, so narrowing to + // one weight class doesn't make the other classes' counts vanish (the + // same reasoning PeopleFilter's authorCounts follows for its own pool) + const preWeightScoped = useMemo(() => { + let out = pulls.filter(p => !boardHidden(p)); + if (scope.repos.length) out = out.filter(p => scope.repos.includes(p.data.repo)); + // bots bypass the people filter on purpose: dependency bumps need review + // no matter whose work you follow (they land in the bots fold, not lanes) + if (scope.authors.length) + out = out.filter(p => isBot(p) || scope.authors.includes(p.data.user.login)); + // the exclusion scope: "everyone except" — bots ride along, same as the + // allow-list above (a dependency bump is nobody's teammate) + if (scope.notAuthors.length) + out = out.filter(p => isBot(p) || !scope.notAuthors.includes(p.data.user.login)); + if (query) out = out.filter(p => matchesQuery(p, query, me, names)); + return out; + }, [pulls, boardHidden, scope, isBot, query, names, me]); + + // preWeightScoped narrowed by the Weight filter, but not yet State — + // StateFilter's own live counts read this pool for the same + // self-vanishing-count reason preWeightScoped exists for WeightFilter + const preStateScoped = useMemo( + () => + weightSel.length + ? preWeightScoped.filter(p => matchesWeightFilter(p, weightSel)) + : preWeightScoped, + [preWeightScoped, weightSel] + ); + + const scoped = useMemo( + () => + stateSel.length + ? preStateScoped.filter(p => stateSel.includes(actionState(p, me))) + : preStateScoped, + [preStateScoped, stateSel, me] + ); + + // memoized so the arrays keep their identity across unrelated re-renders + // (each keystroke, settings toggle, and heartbeat re-runs App) + const humans = useMemo(() => scoped.filter(p => !isBot(p)), [scoped, isBot]); + const bots = useMemo(() => scoped.filter(isBot), [scoped, isBot]); + // every known repo with its open-PR count — feeds the Filters popover and + // the Settings repo manager. Includes pref'd repos at 0. + const repoCounts = useMemo(() => { + const m = new Map(); + for (const name of Object.keys(settings.repoPrefs)) m.set(name, 0); + for (const p of pulls) m.set(p.data.repo, (m.get(p.data.repo) ?? 0) + 1); + return [...m.entries()] + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); + }, [pulls, settings.repoPrefs]); + + // "recently shipped" must follow the same scope as the open lanes above it — + // narrowing to one repo or author shouldn't still show the whole org's + // merges. Closed PRs are PullData (no derived cryo/reveal), so apply the + // durable repo visibility plus the active repo/author scope. + const scopedClosed = useMemo(() => { + let out = closed; + if (!showAll) + out = out.filter( + p => + !isHiddenFor( + { login: p.user.login, repo: p.repo, isBot: isBotLogin(p.user.login, extraBots) }, + settings, + me, + revealedRepo, + revealedAuthor + ) + ); + if (scope.repos.length) out = out.filter(p => scope.repos.includes(p.repo)); + if (scope.authors.length) out = out.filter(p => scope.authors.includes(p.user.login)); + return out; + }, [ + closed, + showAll, + settings.repoPrefs, + settings.hiddenPeople, + revealedRepo, + revealedAuthor, + scope, + me, + extraBots, + ]); + + // the hidden-PR ledger's numbers: stable per-category sizes (what each + // rule covers, whether or not a session reveal currently shows it) plus + // the live currently-hidden total for the trigger label + const hiddenCounts = useMemo(() => { + const c = { parked: 0, drafts: 0, hiddenRepos: 0, hiddenPeople: 0, bots: 0, hiddenNow: 0 }; + for (const p of pulls) { + if (p.cryo) c.parked++; + if (p.data.draft && p.data.user.login !== me && !reviewRequestedFrom(p, me)) c.drafts++; + if (repoHidden(p.data.repo, settings.repoPrefs)) c.hiddenRepos++; + if ( + p.data.user.login !== me && + !isBot(p) && + personHidden(p.data.user.login, settings.hiddenPeople) + ) + c.hiddenPeople++; + if (isBot(p)) c.bots++; + if (boardHidden(p)) c.hiddenNow++; + } + return c; + }, [pulls, me, settings.repoPrefs, settings.hiddenPeople, isBot, boardHidden]); + + const isScoped = scope.repos.length || scope.authors.length || query; + + // what a "Save current filter…" click right now would capture — reads the + // same hashState the write-effect above builds, so a saved filter's hash + // always matches what location.hash would read at this moment + const currentHash = useMemo(() => buildHash(hashState), [hashState]); + // gates the saved-filters panel's "Save current filter…" row (query box) + // and its muted hint row (the header "Saved" menu) — one definition + // (FilterChips.tsx) so the two surfaces can't disagree with FilterChips' + // own "Clear filters" about what counts as active + const sessionActive = hasActiveFilters({ + reveal, + showAll, + draftsMode, + defaultDraftsMode: settings.draftsMode, + scope, + weightSel, + stateSel, + }); + + // every search both doors show; the pinned ones become the bar's chips + const allSearches = useAllSavedFilters(); + const pinnedSearches = useMemo(() => allSearches.filter(f => f.pinned), [allSearches]); + + // an avatar click anywhere lands on that person's board: their login + // becomes the authors scope (visible in the bar, clearable there too) + // and the Team lens shows the result + const onPerson = useCallback( + (login: string) => { + setScope({ ...scope, authors: [login], notAuthors: [] }); + setLens('team'); + }, + [scope, setScope] + ); + // the whole-board parent lookup for stacked pulls: built once over every + // pull (not the scoped/filtered view a given lane renders), so a row whose + // parent got filtered out of ITS list can still name it (see model/stack.ts) + const parentOf = useMemo(() => buildParentLookup(pulls), [pulls]); + // the age baseline is RELATIVE: the board's longest-open pull sets the + // full track, everything else is a fraction of it — "how long has this + // waited, relative to what waiting looks like here" + const maxAgeDays = useMemo(() => pulls.reduce((m, p) => Math.max(m, p.ageDays), 1), [pulls]); + // stable identity so memo(Row) can skip untouched rows on socket bursts. + // laneCap resolves per-lens (falling back to the global default) — safe to + // read the current `lens` here because only one lens's view ever mounts at + // a time, so rowOpts never has to carry more than one lens' cap at once. + const rowOpts: RowOptions = useMemo( + () => ({ + me, + lastSeen, + onPerson, + maxAgeDays, + ageWarnDays: settings.ageWarnDays, + ageRotDays: ageRotDays(settings.ageWarnDays), + ageDisplay: settings.ageDisplay, + compact: settings.density === 'compact', + laneCap: settings.laneCapByLens[lens] ?? settings.laneCap, + parentOf, + pools, + turns, + isBotAuthor, + }), + [ + me, + lastSeen, + onPerson, + maxAgeDays, + settings.ageWarnDays, + settings.ageDisplay, + settings.density, + settings.laneCap, + settings.laneCapByLens, + lens, + parentOf, + pools, + turns, + isBotAuthor, + ] + ); + + const mineCount = humans.filter(p => p.data.user.login === me).length; + // a tab's count rides as the corner badge (out of flow), so it appearing + // when data lands can never widen the tab and shove the strip sideways + const tab = (id: Lens, label: string, count?: number) => ( + + ); + + // The "recently shipped" fold only exists on Review and My work — the + // other four lenses have nowhere for the shipped-catch-up toast to jump to. + // Its count must match what that fold will actually render (scopedClosed + // for Review, self-authored closed for My work), not the raw org-wide + // closed count, or the toast could promise a jump the fold can't deliver + // (count 0, fold not rendered at all). + const shippedFoldId = + lens === 'review' ? 'review:shipped' : lens === 'mine' ? 'mine:shipped' : null; + const shippedFoldCount = + lens === 'review' + ? scopedClosed.length + : lens === 'mine' + ? closed.filter(p => p.user.login === me).length + : 0; + // memoized: this feeds the shippedExtras useMemo below, which feeds + // useToasts' extras effect — a fresh identity every render would re-run + // that chain on every unrelated keystroke/toggle while on these lenses + const jumpToShipped = useMemo( + () => + shippedFoldId + ? () => { + const id = shippedFoldId; + openFold(id); + // openFold's write lands via useSyncExternalStore, so the fold's + // `open` attribute (and the height its rows add) commits on the + // next paint, not synchronously in this handler — wait a frame so + // the scroll lands on the expanded fold, not the collapsed one. + requestAnimationFrame(() => { + const el = document.getElementById(foldDomId(id)); + if (!el) return; + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + el.classList.add('fold-flash'); + setTimeout(() => el.classList.remove('fold-flash'), 900); + }); + } + : undefined, + [shippedFoldId] + ); + + // the "shipped while you were away" catch-up, now a single info toast instead + // of a standing banner. Scope-filtered (respects the active repo/author/hidden + // filters via scopedClosed) and relevance-gated inside shippedToast (yours or + // reviewed only) so an org merge you never touched never interrupts. Suppressed + // entirely while a text query is active — you're searching, don't distract. + const shippedExtras = useMemo(() => { + if (query) return []; + const backlog = scopedClosed.filter( + p => (closedEpoch(p) || 0) > lastSeen && shipRelevance(p, me) !== null + ); + const base = shippedToast(backlog, me); + if (!base) return []; + const jump = shippedFoldCount > 0 ? jumpToShipped : undefined; + // NOTE: dismissing this toast no longer touches the last-cleared + // baseline — a toast quietly expiring was wiping the "Recently + // updated" ledger; only that lane's Clear button moves the state + return [{ ...base, onAct: jump }]; + }, [query, scopedClosed, lastSeen, me, jumpToShipped, shippedFoldCount]); + + // the quick-wins toast filters the board to the small reviewable ones on the + // review lens, instead of scrolling to just the first of the batch + const onQuickWins = useCallback(() => { + setLens('review'); + setWeightSel(['XS', 'S']); + }, []); + // the "your turn" toast's Claim button: claim the review straight from the + // nudge (which also adds you as a GitHub reviewer, same as any claim) + const onClaimTurn = useCallback( + (repo: string, number: number) => { + const p = pulls.find(x => x.data.repo === repo && x.data.number === number); + if (p) claimReview(p.data); + }, + [pulls] + ); + // pull keys the viewer snoozed in Review, so cheers skips them the way the + // Review lane does — a snooze is "not today", not "nudge me anyway" + const snoozedKeys = useMemo( + () => + new Set(nudgeablePulls.filter(p => isSnoozed(p.data, snoozed)).map(p => pullKey(p.data))), + [nudgeablePulls, snoozed] + ); + const { + toasts, + dismiss: dismissToast, + history: toastHistory, + clearHistory, + dismissHistoryItem, + } = useToasts( + nudgeablePulls, + me, + turns, + shippedExtras, + closed, + onQuickWins, + onClaimTurn, + initialized, + names, + snoozedKeys + ); + + return ( + <> + +
+ {/* Logo + lenses share one line. The logo and the right-hand + controls both float out past the centered content into the + gutters — but only as far as a 1536px cap, so an ultrawide + display parks them a fixed ~150px outside the content instead of + flinging them to the screen edges. The wordmark collapses to the + mark, and the open-count / v1 link hide, once the gutter is too + tight (below 2xl); the tabs reserve pl/pr for the floats below + that so nothing collides. The floats carry z-[1] so they stay + clickable over the tab row's own box. */} +
+ {/* the page's one h1 — heading navigation needs a root, and + every lane h2 needs a parent level. pointer-events-none: + nothing in it is interactive, and its box spans the hidden + wordmark's width — at narrow widths that invisible span sat + over the Review tab / lens menu at z-[1] and ate their + clicks (prod report) */} +

+ + +

+ {/* inset-y-0 + items-center for vertical centering, NOT + -translate-y-1/2: a transform on this ancestor would re-base + position:fixed for the Settings drawer / popovers rendered + under it, leaving them mispositioned (invisible). */} +
+ {refreshProgress && ( + // no spinner, no color — the changing number is the motion, + // same wording Settings' Data group shows for the same state. + // Out of flow (anchored left of the cluster): transient text + // must not shove the bell/legend/cog sideways mid-aim. + + {refreshProgress.done === refreshProgress.total + ? `refreshed ${refreshProgress.total}` + : `refreshing ${refreshProgress.done} of ${refreshProgress.total}`} + + )} + + live updates {connection} + + {/* numeral slot reserved (and blank until data lands) so the + count fading in doesn't nudge the connection dot on load */} + + + {initialized + ? isScoped + ? `${scoped.length} of ${pulls.length}` + : pulls.length + : ''} + {' '} + open + + + v1 board + + + {/* the symbol legend is a reference, not an action — drop it + on phones to give the header controls their room back */} + + + + setLens('team')} /> +
+
+ {/* min-w-0 + overflow-x-auto (no-scrollbar in styles.css) + turns a too-narrow tab strip into a swipe rather than a + wrap ("My work 5" splitting) or a page-widening overflow. + Below sm the strip no longer fits at all and that swipe + is undiscoverable on a phone, so it's hidden there in + favor of LensMenu's dropdown twin. */} + {/* py-1/-my-1: the scroll container clips its content box, and + the My-work corner badge rides 4px above its tab — the + padding gives the badge headroom inside the clip without + moving the strip (prod report: badge top shaved off) */} + + ({ + id, + label: LENS_LABELS[id], + count: id === 'mine' ? mineCount : undefined, + }))} + /> +
+
+
+ {/* pinned saved searches, FIRST in the bar so a growing + trigger can never displace them: every roster earns one + automatically (kept in sync with its members), and any + search can be pinned from the Saved menu. Click applies + it on the lens you're standing on; click again clears. + State is carried entirely by color — the chip's text + never changes, so nothing ever shifts. */} + {pinnedSearches.map(f => { + const active = matchesView(f.hash, currentHash); + const title = active + ? `showing ${f.name}: click to clear` + : `show ${f.name}: ${describeHash(f.hash)}`; + return ( + + ); + })} + + + + + + + {sessionActive && ( + + )} + + {bots.length > 0 && ( + + {n(bots.length, 'bot PR')} + + )} + +
+
+ + {authFailed && ( + + Sign-in failed. + + Your session may have expired.{' '} + + Reload to sign in again + + . + + + )} + {initialized && + !authFailed && + (connection === 'disconnected' || connection === 'error') && ( + + Live updates lost. + + Showing data as of {lastPayloadAt ? `${ago(lastPayloadAt)} ago` : 'page load'}, + retrying in the background. + + + )} +
+ {!initialized && !authFailed && ( +
+ + Loading the board… +
+ )} + {initialized && lens === 'review' && ( + + )} + {initialized && lens === 'mine' && ( + + )} + {initialized && lens === 'team' && ( + !isBot(p))} + selected={scope.authors} + onSelect={logins => setScope({ ...scope, authors: logins, notAuthors: [] })} + opts={rowOpts} + extraBots={extraBots} + /> + )} + {initialized && lens === 'classic' && } + {initialized && lens === 'ci' && } + {initialized && lens === 'stats' && ( + + )} +
+ + ); +} diff --git a/frontend-v2/src/backend/dummy-pulls.json b/frontend-v2/src/backend/dummy-pulls.json new file mode 100644 index 00000000..98496d59 --- /dev/null +++ b/frontend-v2/src/backend/dummy-pulls.json @@ -0,0 +1,8092 @@ +[ + { + "repo": "iFixit/ifixit", + "number": 33495, + "state": "open", + "title": "IE11: Run in parallel with chrome tests instead of sequentially", + "body": "pull request dummy body", + "created_at": "2020-07-08T22:55:22.000Z", + "updated_at": "2020-10-16T21:58:04.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "ie11--run-in-parallel-with-chrome-tests", + "sha": "ee18a55031b8b1e86dbe240552dfe86f344559d7", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "BaseInfinity" + }, + "assignees": [ + "danielbeardsley" + ], + "requested_reviewers": [ + "ianrohde", + "danielbeardsley" + ], + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 33495, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "CR", + "created_at": "2020-07-08T23:47:35.000Z", + "active": 1, + "comment_id": 655814623 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 33495, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "CR", + "created_at": "2020-07-08T23:47:35.000Z", + "active": 1, + "comment_id": 655814623 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 33495, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "CR", + "created_at": "2020-07-08T23:10:20.000Z", + "active": 0, + "comment_id": 655803840 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 33495, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "dev_block", + "created_at": "2020-07-08T23:10:20.000Z", + "active": 1, + "comment_id": 655803840 + } + } + ], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "ee18a55031b8b1e86dbe240552dfe86f344559d7", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "ee18a55031b8b1e86dbe240552dfe86f344559d7", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "ee18a55031b8b1e86dbe240552dfe86f344559d7", + "target_url": "https://wwww.example.com", + "description": "Merge Failed", + "state": "error", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "QAE", + "number": 33495, + "repo": "iFixit/ifixit", + "user": "davidrans", + "created_at": "2020-07-23T19:51:19.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 33597, + "state": "open", + "title": "Font: Change from Lato to Inter", + "body": "pull request dummy body", + "created_at": "2020-07-17T17:58:11.000Z", + "updated_at": "2020-12-16T01:10:08.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "test-inter-font", + "sha": "a22a3d8e132c90c21b535b099a2d6e21b5520098", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "participants": [ + "ianrohde", + "Another" + ], + "user": { + "login": "ianrohde" + }, + "assignees": [ + "danielbeardsley", + "ianrohde" + ], + "requested_reviewers": [ + "rjmccluskey" + ], + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 33597, + "user": { + "id": 4808, + "login": "scbarber" + }, + "type": "CR", + "created_at": "2020-07-30T22:51:27.000Z", + "active": 0, + "comment_id": 666758987 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 33597, + "user": { + "id": 4808, + "login": "scbarber" + }, + "type": "CR", + "created_at": "2020-07-30T20:45:46.000Z", + "active": 0, + "comment_id": 666684901 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 33597, + "user": { + "id": 129007, + "login": "kwiens" + }, + "type": "dev_block", + "created_at": "2020-08-28T15:30:11.000Z", + "active": 1, + "comment_id": 682715246 + } + } + ], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 33597, + "user": { + "id": 1424113, + "login": "BaseInfinity" + }, + "type": "deploy_block", + "created_at": "2020-08-27T19:37:53.000Z", + "active": 1, + "comment_id": 682151085 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 33597, + "user": { + "id": 129007, + "login": "kwiens" + }, + "type": "deploy_block", + "created_at": "2020-07-17T22:53:29.000Z", + "active": 1, + "comment_id": 660370664 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "a22a3d8e132c90c21b535b099a2d6e21b5520098", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "a22a3d8e132c90c21b535b099a2d6e21b5520098", + "target_url": "https://wwww.example.com", + "description": "Build failure", + "state": "failure", + "context": "phpunit" + } + }, + { + "data": { + "sha": "a22a3d8e132c90c21b535b099a2d6e21b5520098", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "QAing", + "number": 33597, + "repo": "iFixit/ifixit", + "user": "k0rvusk0r4x", + "created_at": "2020-08-27T22:31:56.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 34221, + "state": "open", + "title": "Cap hosts tasks: Drop old tasks", + "body": "pull request dummy body", + "created_at": "2020-09-04T01:28:45.000Z", + "updated_at": "2020-11-18T23:15:50.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "hosts--always-use-dynamic", + "sha": "167b03a21379d9440f8269c2dfc2f81c1877c64d", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "danielbeardsley" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 34221, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-09-04T20:08:21.000Z", + "active": 0, + "comment_id": 687356997 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 34221, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "CR", + "created_at": "2020-09-04T16:49:32.000Z", + "active": 0, + "comment_id": 687264914 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 34221, + "user": { + "id": 589425, + "login": "sterlinghirsh" + }, + "type": "CR", + "created_at": "2020-09-04T10:07:57.000Z", + "active": 0, + "comment_id": 687054656 + } + } + ], + "dev_block": [], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 34221, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "deploy_block", + "created_at": "2020-11-12T20:10:36.000Z", + "active": 1, + "comment_id": 726313998 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 34221, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "deploy_block", + "created_at": "2020-09-08T17:33:28.000Z", + "active": 1, + "comment_id": 689029370 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "167b03a21379d9440f8269c2dfc2f81c1877c64d", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "167b03a21379d9440f8269c2dfc2f81c1877c64d", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "167b03a21379d9440f8269c2dfc2f81c1877c64d", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "external_block", + "number": 34221, + "repo": "iFixit/ifixit", + "user": "ardelato", + "created_at": "2020-11-18T23:15:50.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 34925, + "state": "open", + "title": "Grafana: Provisioning scan takes too long causing setting page to fail on save.", + "body": "pull request dummy body", + "created_at": "2020-10-28T01:02:33.000Z", + "updated_at": "2020-11-10T22:16:17.000Z", + "participants": [ + "danielbeardsley", + "someone else" + ], + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "grafana--async-provisioning-scan", + "sha": "2c1e0b325b221799eecc245b359f8a77093be19e", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "hackalot805" + }, + "assignees": [ + "ardelato" + ], + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 34925, + "user": { + "id": 1424113, + "login": "BaseInfinity" + }, + "type": "CR", + "created_at": "2020-10-30T16:31:36.000Z", + "active": 0, + "comment_id": 719657649 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 34925, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "dev_block", + "created_at": "2020-11-03T17:20:21.000Z", + "active": 1, + "comment_id": 721267100 + } + } + ], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 34925, + "user": { + "id": 1424113, + "login": "BaseInfinity" + }, + "type": "deploy_block", + "created_at": "2020-10-30T16:31:36.000Z", + "active": 1, + "comment_id": 719657649 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "2c1e0b325b221799eecc245b359f8a77093be19e", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "2c1e0b325b221799eecc245b359f8a77093be19e", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "2c1e0b325b221799eecc245b359f8a77093be19e", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "QAing", + "number": 34925, + "repo": "iFixit/ifixit", + "user": "JRodger312", + "created_at": "2020-10-29T19:35:58.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 34964, + "state": "open", + "title": "Quoted comments change quote-block visibility", + "body": "pull request dummy body", + "created_at": "2020-10-30T22:41:53.000Z", + "updated_at": "2020-10-30T22:41:53.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "guide-comments--change-quoteblock-visibility", + "sha": "64bf41772407e98112f173eeb75b7118096203d1", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "ardelato" + }, + "cr_req": 2, + "requested_reviewers": [ + "danielbeardsley", + "hackalot805" + ], + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 34964, + "user": { + "id": 10889879, + "login": "addison-grant" + }, + "type": "dev_block", + "created_at": "2020-10-30T23:24:36.000Z", + "active": 1, + "comment_id": 719842423 + } + } + ], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "64bf41772407e98112f173eeb75b7118096203d1", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "64bf41772407e98112f173eeb75b7118096203d1", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "64bf41772407e98112f173eeb75b7118096203d1", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35059, + "state": "open", + "title": "Make release-gate-sso-approvals-signoffs user editable", + "body": "pull request dummy body", + "created_at": "2020-11-10T00:46:05.000Z", + "updated_at": "2020-11-19T00:33:38.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "sso--saml2-signoff-setting-management", + "sha": "decdcfe240c9916c48be521e4420ca92e7cbd3c0", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "sso--change-default-sso-approvals-signoffs-setting" + }, + "user": { + "login": "rjmccluskey" + }, + "assignees": [ + "hackalot805", + "ardelato" + ], + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35059, + "user": { + "id": 41349604, + "login": "JRodger312" + }, + "type": "QA", + "created_at": "2020-11-20T21:42:20.000Z", + "active": 1, + "comment_id": 731422032 + } + } + ], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35059, + "user": { + "id": 1248182, + "login": "cdcline" + }, + "type": "CR", + "created_at": "2020-11-18T23:34:35.000Z", + "active": 1, + "comment_id": 730023937 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35059, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-11-18T21:48:32.000Z", + "active": 1, + "comment_id": 729977256 + } + } + ], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35059, + "user": { + "id": 41349604, + "login": "JRodger312" + }, + "type": "QA", + "created_at": "2020-11-20T21:42:20.000Z", + "active": 1, + "comment_id": 731422032 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35059, + "user": { + "id": 41349604, + "login": "JRodger312" + }, + "type": "QA", + "created_at": "2020-11-17T16:54:42.000Z", + "active": 0, + "comment_id": 729060099 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35059, + "user": { + "id": 33426643, + "login": "k0rvusk0r4x" + }, + "type": "QA", + "created_at": "2020-11-11T19:08:36.000Z", + "active": 0, + "comment_id": 725606276 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35059, + "user": { + "id": 1248182, + "login": "cdcline" + }, + "type": "CR", + "created_at": "2020-11-18T23:34:35.000Z", + "active": 1, + "comment_id": 730023937 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35059, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-11-18T21:48:32.000Z", + "active": 1, + "comment_id": 729977256 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35059, + "user": { + "id": 1248182, + "login": "cdcline" + }, + "type": "CR", + "created_at": "2020-11-16T23:47:05.000Z", + "active": 0, + "comment_id": 728431243 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35059, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-11-13T16:38:20.000Z", + "active": 0, + "comment_id": 726866056 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35059, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-11-12T16:41:26.000Z", + "active": 0, + "comment_id": 726194620 + } + } + ], + "dev_block": [], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35059, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "deploy_block", + "created_at": "2020-11-10T00:46:31.000Z", + "active": 1, + "comment_id": 724374955 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "decdcfe240c9916c48be521e4420ca92e7cbd3c0", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "decdcfe240c9916c48be521e4420ca92e7cbd3c0", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "decdcfe240c9916c48be521e4420ca92e7cbd3c0", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "QAing", + "number": 35059, + "repo": "iFixit/ifixit", + "user": "JRodger312", + "created_at": "2020-11-19T00:33:38.000Z" + }, + { + "title": "squad-application", + "number": 35059, + "repo": "iFixit/ifixit", + "user": "raquelmsmith", + "created_at": "2020-11-13T22:56:45.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35103, + "state": "open", + "title": "Add migration to flip default 'release-gate-sso-approvals-signoffs' setting", + "body": "pull request dummy body", + "created_at": "2020-11-12T22:46:37.000Z", + "updated_at": "2020-11-19T17:20:17.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "Dozuki - 12/01/2020", + "due_on": "2020-12-01T08:00:00.000Z" + }, + "head": { + "ref": "sso--change-default-sso-approvals-signoffs-setting", + "sha": "ebe411f77d67f418b2bcfe59fc166218c9f76f24", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "rjmccluskey" + }, + "cr_req": 2, + "qa_req": 0, + "status": { + "qa_req": 0, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35103, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "CR", + "created_at": "2020-11-16T20:06:15.000Z", + "active": 1, + "comment_id": 728294783 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35103, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-11-13T16:36:35.000Z", + "active": 1, + "comment_id": 726865047 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35103, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "CR", + "created_at": "2020-11-16T20:06:15.000Z", + "active": 1, + "comment_id": 728294783 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35103, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-11-13T16:36:35.000Z", + "active": 1, + "comment_id": 726865047 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "ebe411f77d67f418b2bcfe59fc166218c9f76f24", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "ebe411f77d67f418b2bcfe59fc166218c9f76f24", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "ebe411f77d67f418b2bcfe59fc166218c9f76f24", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "squad-application", + "number": 35103, + "repo": "iFixit/ifixit", + "user": "raquelmsmith", + "created_at": "2020-11-13T22:56:45.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 351011, + "state": "open", + "title": "Add migration to flip default 'release-gate-sso-approvals-signoffs' setting", + "body": "pull request dummy body", + "created_at": "2020-11-12T22:46:37.000Z", + "updated_at": "2020-11-19T17:20:17.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "Dozuki - 12/01/2020", + "due_on": "2020-12-01T08:00:00.000Z" + }, + "head": { + "ref": "sso--change-default-sso-approvals-signoffs-setting", + "sha": "ebe411f77d67f418b2bcfe59fc166218c9f76f24", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "danielbeardsley" + }, + "cr_req": 2, + "qa_req": 0, + "status": { + "qa_req": 0, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35103, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "CR", + "created_at": "2020-11-16T20:06:15.000Z", + "active": 1, + "comment_id": 728294783 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35103, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-11-13T16:36:35.000Z", + "active": 1, + "comment_id": 726865047 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35103, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-11-13T16:36:35.000Z", + "active": 1, + "comment_id": 726865047 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "ebe411f77d67f418b2bcfe59fc166218c9f76f24", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "ebe411f77d67f418b2bcfe59fc166218c9f76f24", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "ebe411f77d67f418b2bcfe59fc166218c9f76f24", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "squad-application", + "number": 35103, + "repo": "iFixit/ifixit", + "user": "raquelmsmith", + "created_at": "2020-11-13T22:56:45.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35129, + "state": "open", + "title": "Training Periods: Remove feature switch for training periods from the DB.", + "body": "pull request dummy body", + "created_at": "2020-11-16T23:32:47.000Z", + "updated_at": "2020-11-19T17:08:53.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "remove-from-db--feature-training-periods", + "sha": "79c221f79a0b61952722cb25389c91ae4dafe340", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "hackalot805" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35129, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "dev_block", + "created_at": "2020-11-17T20:54:38.000Z", + "active": 1, + "comment_id": 729207978 + } + } + ], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35129, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "deploy_block", + "created_at": "2020-11-16T23:33:14.000Z", + "active": 1, + "comment_id": 728408477 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "79c221f79a0b61952722cb25389c91ae4dafe340", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "79c221f79a0b61952722cb25389c91ae4dafe340", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "79c221f79a0b61952722cb25389c91ae4dafe340", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35154, + "state": "open", + "title": "WDCloudSearchTest: Remove dependency on iFixit and parallelyze it", + "body": "pull request dummy body", + "created_at": "2020-11-18T07:56:39.000Z", + "updated_at": "2020-11-19T00:16:18.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "Dozuki - 12/01/2020", + "due_on": "2020-12-01T08:00:00.000Z" + }, + "head": { + "ref": "wdcloudsearchtest--remove-dependency-on-papa-fixit", + "sha": "3e87476c711c3d3acd2828dbe604664754dc9e3e", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "BaseInfinity" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35154, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-11-18T17:11:41.000Z", + "active": 0, + "comment_id": 729821763 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35154, + "user": { + "id": 1424113, + "login": "BaseInfinity" + }, + "type": "dev_block", + "created_at": "2020-11-18T18:43:50.000Z", + "active": 1, + "comment_id": 729879293 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35154, + "user": { + "id": 29667789, + "login": "mlahargou" + }, + "type": "dev_block", + "created_at": "2020-11-18T17:54:40.000Z", + "active": 1, + "comment_id": 729850478 + } + } + ], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35154, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "deploy_block", + "created_at": "2020-11-18T17:11:41.000Z", + "active": 1, + "comment_id": 729821763 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "3e87476c711c3d3acd2828dbe604664754dc9e3e", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "3e87476c711c3d3acd2828dbe604664754dc9e3e", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "3e87476c711c3d3acd2828dbe604664754dc9e3e", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35168, + "state": "open", + "title": "Shopify Product Export: Handle wiki links per store", + "body": "pull request dummy body", + "created_at": "2020-11-19T16:46:00.000Z", + "updated_at": "2020-11-24T23:04:02.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "iFixit - 12/01/2020", + "due_on": "2020-12-01T08:00:00.000Z" + }, + "head": { + "ref": "shopify-product-export--render-wikis-per-store", + "sha": "b7794fc7bd686810c0a2a44372b61673de7ab26f", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "jyee27" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35168, + "user": { + "id": 589425, + "login": "sterlinghirsh" + }, + "type": "CR", + "created_at": "2020-11-20T10:37:41.000Z", + "active": 0, + "comment_id": 731089968 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35168, + "user": { + "id": 589425, + "login": "sterlinghirsh" + }, + "type": "dev_block", + "created_at": "2020-11-20T10:37:41.000Z", + "active": 1, + "comment_id": 731089968 + } + } + ], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "b7794fc7bd686810c0a2a44372b61673de7ab26f", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "b7794fc7bd686810c0a2a44372b61673de7ab26f", + "target_url": "https://wwww.example.com", + "description": "Build failure", + "state": "failure", + "context": "phpunit" + } + }, + { + "data": { + "sha": "b7794fc7bd686810c0a2a44372b61673de7ab26f", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35177, + "state": "open", + "title": "Shipping Carriers: Allow querying shelf weight", + "body": "pull request dummy body", + "created_at": "2020-11-19T20:00:17.000Z", + "updated_at": "2020-11-30T22:08:10.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "iFixit - 12/01/2020", + "due_on": "2020-12-01T08:00:00.000Z" + }, + "head": { + "ref": "cart--shipping--query-carriers-with-shelf-weight", + "sha": "fb3a93c57f06304cf108ae431800f99dfffd02d3", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "cdcline" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35177, + "user": { + "id": 857362, + "login": "jarstelfox" + }, + "type": "CR", + "created_at": "2020-11-19T20:28:20.000Z", + "active": 0, + "comment_id": 730618736 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35177, + "user": { + "id": 1248182, + "login": "cdcline" + }, + "type": "dev_block", + "created_at": "2020-11-19T20:00:45.000Z", + "active": 1, + "comment_id": 730604328 + } + } + ], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "fb3a93c57f06304cf108ae431800f99dfffd02d3", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "fb3a93c57f06304cf108ae431800f99dfffd02d3", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "fb3a93c57f06304cf108ae431800f99dfffd02d3", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35194, + "state": "open", + "title": "Valkyrie: Allow /Page/all-posts/page/2 type urls", + "body": "pull request dummy body", + "created_at": "2020-11-20T18:46:43.000Z", + "updated_at": "2020-11-30T23:12:06.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "valkyrie-allow-long-page-routes", + "sha": "69ba02129aa88ef73f59d37d83660a3d180db4da", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "jarstelfox" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35194, + "user": { + "id": 58952979, + "login": "batbattur" + }, + "type": "QA", + "created_at": "2020-11-30T23:12:00.000Z", + "active": 1, + "comment_id": 736111068 + } + } + ], + "CR": [], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35194, + "user": { + "id": 58952979, + "login": "batbattur" + }, + "type": "QA", + "created_at": "2020-11-30T23:12:00.000Z", + "active": 1, + "comment_id": 736111068 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35194, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "CR", + "created_at": "2020-11-20T19:51:53.000Z", + "active": 0, + "comment_id": 731375766 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "69ba02129aa88ef73f59d37d83660a3d180db4da", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "69ba02129aa88ef73f59d37d83660a3d180db4da", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "69ba02129aa88ef73f59d37d83660a3d180db4da", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35207, + "state": "open", + "title": "Update core-primitives to 1.2.0 from 0.12.1", + "body": "pull request dummy body", + "created_at": "2020-11-22T21:30:22.000Z", + "updated_at": "2020-12-21T23:17:15.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "core-primitives--update-to-1-1-1", + "sha": "a864b1fe5efdcc9c08c3553c55e896477b74b34b", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "addison-grant" + }, + "cr_req": 3, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 3, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35207, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-21T23:17:15.000Z", + "active": 1, + "comment_id": 749249097 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35207, + "user": { + "id": 1634505, + "login": "ianrohde" + }, + "type": "CR", + "created_at": "2020-12-10T00:20:40.000Z", + "active": 1, + "comment_id": 742147618 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35207, + "user": { + "id": 24502905, + "login": "bhuminson" + }, + "type": "CR", + "created_at": "2020-12-09T23:50:07.000Z", + "active": 1, + "comment_id": 742137133 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35207, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-21T23:17:15.000Z", + "active": 1, + "comment_id": 749249097 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35207, + "user": { + "id": 1634505, + "login": "ianrohde" + }, + "type": "CR", + "created_at": "2020-12-10T00:20:40.000Z", + "active": 1, + "comment_id": 742147618 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35207, + "user": { + "id": 24502905, + "login": "bhuminson" + }, + "type": "CR", + "created_at": "2020-12-09T23:50:07.000Z", + "active": 1, + "comment_id": 742137133 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "a864b1fe5efdcc9c08c3553c55e896477b74b34b", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "a864b1fe5efdcc9c08c3553c55e896477b74b34b", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "a864b1fe5efdcc9c08c3553c55e896477b74b34b", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "external_block", + "number": 35207, + "repo": "iFixit/ifixit", + "user": "addison-grant", + "created_at": "2020-11-24T18:46:54.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35236, + "state": "open", + "title": "Add \"GUIDE_ARCHIVED\" flag to full width flags", + "body": "pull request dummy body", + "created_at": "2020-11-25T18:37:50.000Z", + "updated_at": "2020-11-25T23:56:00.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "flags--archived-major-flag", + "sha": "d68b37267345be9433fc5d2147010be2d3272261", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "rjmccluskey" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35236, + "user": { + "id": 22064420, + "login": "ardelato" + }, + "type": "QA", + "created_at": "2020-11-25T23:55:49.000Z", + "active": 1, + "comment_id": 733996627 + } + } + ], + "CR": [], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35236, + "user": { + "id": 22064420, + "login": "ardelato" + }, + "type": "QA", + "created_at": "2020-11-25T23:55:49.000Z", + "active": 1, + "comment_id": 733996627 + } + } + ], + "allCR": [], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "d68b37267345be9433fc5d2147010be2d3272261", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "d68b37267345be9433fc5d2147010be2d3272261", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "d68b37267345be9433fc5d2147010be2d3272261", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35249, + "state": "open", + "title": "Ready pull that has no CI and should be in Deploy Block column", + "body": "pull request dummy body", + "created_at": "2020-11-30T20:28:50.000Z", + "updated_at": "2020-12-01T15:50:31.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "iFixit - 12/01/2020", + "due_on": "2020-12-01T08:00:00.000Z" + }, + "head": { + "ref": "product-search-improvements--manual-search-keyword-attributes", + "sha": "86b6d9cd5fdb231a6e2c5249aaa745b7428a981c", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "evannoronha" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35258, + "user": { + "id": 58952979, + "login": "batbattur" + }, + "type": "QA", + "created_at": "2020-12-01T01:02:51.000Z", + "active": 1, + "comment_id": 736148899 + } + } + ], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35249, + "user": { + "id": 857362, + "login": "jarstelfox" + }, + "type": "CR", + "created_at": "2020-12-01T09:38:01.000Z", + "active": 1, + "comment_id": 736350879 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35249, + "user": { + "id": 194351, + "login": "sctice" + }, + "type": "CR", + "created_at": "2020-11-30T23:28:15.000Z", + "active": 1, + "comment_id": 736116927 + } + } + ], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35258, + "user": { + "id": 58952979, + "login": "batbattur" + }, + "type": "QA", + "created_at": "2020-12-01T01:02:51.000Z", + "active": 1, + "comment_id": 736148899 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35249, + "user": { + "id": 857362, + "login": "jarstelfox" + }, + "type": "CR", + "created_at": "2020-12-01T09:38:01.000Z", + "active": 1, + "comment_id": 736350879 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35249, + "user": { + "id": 194351, + "login": "sctice" + }, + "type": "CR", + "created_at": "2020-11-30T23:28:15.000Z", + "active": 1, + "comment_id": 736116927 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35249, + "user": { + "id": 194351, + "login": "sctice" + }, + "type": "CR", + "created_at": "2020-11-30T22:29:19.000Z", + "active": 0, + "comment_id": 736094713 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35252, + "state": "open", + "title": "Store dropdown: display US on US", + "body": "pull request dummy body", + "created_at": "2020-11-30T21:28:48.000Z", + "updated_at": "2020-12-01T17:12:16.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "iFixit - 12/01/2020", + "due_on": "2020-12-01T08:00:00.000Z" + }, + "head": { + "ref": "store-dropdown--display-us-on-us", + "sha": "34d4823d9a00c7f1c7a273850b01f03add40204c", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "zdmitchell" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35252, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-12-01T17:24:33.000Z", + "active": 1, + "comment_id": 736699963 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35252, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-12-01T17:24:33.000Z", + "active": 1, + "comment_id": 736699963 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "34d4823d9a00c7f1c7a273850b01f03add40204c", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "34d4823d9a00c7f1c7a273850b01f03add40204c", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "34d4823d9a00c7f1c7a273850b01f03add40204c", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "QAing", + "number": 35252, + "repo": "iFixit/ifixit", + "user": "ardelato", + "created_at": "2020-12-01T17:12:16.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35255, + "state": "open", + "title": "WDWarehouseTransferTest: Deflake and robustify ", + "body": "pull request dummy body", + "created_at": "2020-11-30T22:27:24.000Z", + "updated_at": "2020-12-01T16:45:52.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "tests--deflake-warehouse-transfer-test", + "sha": "eba3c6a5c9f1d691490a79eab8ec1a1df166698c", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "mlahargou" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35255, + "user": { + "id": 857362, + "login": "jarstelfox" + }, + "type": "CR", + "created_at": "2020-12-01T09:43:22.000Z", + "active": 1, + "comment_id": 736358617 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35255, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-11-30T23:31:37.000Z", + "active": 1, + "comment_id": 736118110 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35255, + "user": { + "id": 857362, + "login": "jarstelfox" + }, + "type": "CR", + "created_at": "2020-12-01T09:43:22.000Z", + "active": 1, + "comment_id": 736358617 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35255, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-11-30T23:31:37.000Z", + "active": 1, + "comment_id": 736118110 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35255, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-11-30T22:59:13.000Z", + "active": 0, + "comment_id": 736106240 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "eba3c6a5c9f1d691490a79eab8ec1a1df166698c", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "eba3c6a5c9f1d691490a79eab8ec1a1df166698c", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "eba3c6a5c9f1d691490a79eab8ec1a1df166698c", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "QAing", + "number": 35255, + "repo": "iFixit/ifixit", + "user": "k0rvusk0r4x", + "created_at": "2020-12-01T16:45:52.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35256, + "state": "open", + "title": "Add rel=\"noopener noreferrer\" to some external links", + "body": "pull request dummy body", + "created_at": "2020-11-30T22:54:48.000Z", + "updated_at": "2020-12-01T16:51:37.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "html--add-rel-noopener-noreferrer", + "sha": "f9395a5e2fc082c49b99cbcaca8d2b074aed2829", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "scbarber" + }, + "cr_req": 3, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 3, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35256, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-11-30T23:29:44.000Z", + "active": 1, + "comment_id": 736117456 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35256, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "CR", + "created_at": "2020-11-30T23:19:47.000Z", + "active": 1, + "comment_id": 736113907 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35256, + "user": { + "id": 3336250, + "login": "caphene" + }, + "type": "CR", + "created_at": "2020-11-30T23:18:42.000Z", + "active": 1, + "comment_id": 736113458 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35256, + "user": { + "id": 1634505, + "login": "ianrohde" + }, + "type": "CR", + "created_at": "2020-11-30T23:15:11.000Z", + "active": 1, + "comment_id": 736112239 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35256, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "CR", + "created_at": "2020-11-30T23:05:09.000Z", + "active": 1, + "comment_id": 736108576 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35256, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-11-30T23:29:44.000Z", + "active": 1, + "comment_id": 736117456 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35256, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "CR", + "created_at": "2020-11-30T23:19:47.000Z", + "active": 1, + "comment_id": 736113907 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35256, + "user": { + "id": 3336250, + "login": "caphene" + }, + "type": "CR", + "created_at": "2020-11-30T23:18:42.000Z", + "active": 1, + "comment_id": 736113458 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35256, + "user": { + "id": 1634505, + "login": "ianrohde" + }, + "type": "CR", + "created_at": "2020-11-30T23:15:11.000Z", + "active": 1, + "comment_id": 736112239 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35256, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "CR", + "created_at": "2020-11-30T23:05:09.000Z", + "active": 1, + "comment_id": 736108576 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "f9395a5e2fc082c49b99cbcaca8d2b074aed2829", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "f9395a5e2fc082c49b99cbcaca8d2b074aed2829", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "f9395a5e2fc082c49b99cbcaca8d2b074aed2829", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "QAing", + "number": 35256, + "repo": "iFixit/ifixit", + "user": "batbattur", + "created_at": "2020-12-01T16:51:37.000Z" + }, + { + "title": "security", + "number": 35256, + "repo": "iFixit/ifixit", + "user": "scbarber", + "created_at": "2020-11-30T22:54:48.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35258, + "state": "open", + "title": "WDAddressVerificationTest: Ensure button is still", + "body": "pull request dummy body", + "created_at": "2020-11-30T23:24:22.000Z", + "updated_at": "2020-11-30T23:24:22.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "tests--deflake-wd-address-verification-test-2", + "sha": "f7b399029f0fddf893560e630b761a1b0cea1def", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "mlahargou" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35258, + "user": { + "id": 58952979, + "login": "batbattur" + }, + "type": "QA", + "created_at": "2020-12-01T01:02:51.000Z", + "active": 1, + "comment_id": 736148899 + } + } + ], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35258, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "CR", + "created_at": "2020-12-01T16:24:05.000Z", + "active": 1, + "comment_id": 736660821 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35258, + "user": { + "id": 857362, + "login": "jarstelfox" + }, + "type": "CR", + "created_at": "2020-12-01T09:41:33.000Z", + "active": 1, + "comment_id": 736356655 + } + } + ], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35258, + "user": { + "id": 58952979, + "login": "batbattur" + }, + "type": "QA", + "created_at": "2020-12-01T01:02:51.000Z", + "active": 1, + "comment_id": 736148899 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35258, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "CR", + "created_at": "2020-12-01T16:24:05.000Z", + "active": 1, + "comment_id": 736660821 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35258, + "user": { + "id": 857362, + "login": "jarstelfox" + }, + "type": "CR", + "created_at": "2020-12-01T09:41:33.000Z", + "active": 1, + "comment_id": 736356655 + } + } + ], + "dev_block": [], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35258, + "user": { + "id": 857362, + "login": "jarstelfox" + }, + "type": "deploy_block", + "created_at": "2020-12-01T09:41:33.000Z", + "active": 1, + "comment_id": 736356655 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "f7b399029f0fddf893560e630b761a1b0cea1def", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "f7b399029f0fddf893560e630b761a1b0cea1def", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "f7b399029f0fddf893560e630b761a1b0cea1def", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35261, + "state": "open", + "title": "Verify Card: remove unused, broken tool", + "body": "pull request dummy body", + "created_at": "2020-12-01T00:16:29.000Z", + "updated_at": "2020-12-22T22:47:11.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "security--sanitize-file-upload", + "sha": "3510dbb8a56227c9e43a1bc7cfcac736ccf2e8b5", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "scbarber" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35261, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "CR", + "created_at": "2020-12-01T15:46:05.000Z", + "active": 0, + "comment_id": 736637185 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35261, + "user": { + "id": 3336250, + "login": "caphene" + }, + "type": "CR", + "created_at": "2020-12-01T00:21:29.000Z", + "active": 0, + "comment_id": 736134645 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "3510dbb8a56227c9e43a1bc7cfcac736ccf2e8b5", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "3510dbb8a56227c9e43a1bc7cfcac736ccf2e8b5", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "3510dbb8a56227c9e43a1bc7cfcac736ccf2e8b5", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "security", + "number": 35261, + "repo": "iFixit/ifixit", + "user": "scbarber", + "created_at": "2020-12-01T00:16:29.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35263, + "state": "open", + "title": "Grafana: Add a custom Parts and Tools dashboard for customer.", + "body": "pull request dummy body", + "created_at": "2020-12-01T00:59:10.000Z", + "updated_at": "2020-12-01T17:05:01.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "grafana--add-custom-dashboard", + "sha": "1291c5adfd85433c17ba68b6acb33510edaf7418", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "sivadnor" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "1291c5adfd85433c17ba68b6acb33510edaf7418", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "1291c5adfd85433c17ba68b6acb33510edaf7418", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "1291c5adfd85433c17ba68b6acb33510edaf7418", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "QAing", + "number": 35263, + "repo": "iFixit/ifixit", + "user": "ardelato", + "created_at": "2020-12-01T17:05:01.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35272, + "state": "open", + "title": "Manifesto: Add Danish manifesto", + "body": "pull request dummy body", + "created_at": "2020-12-01T17:17:37.000Z", + "updated_at": "2020-12-01T17:29:48.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "Backlog", + "due_on": null + }, + "head": { + "ref": "manifesto--add-danish-manifesto", + "sha": "34686acedd0bc6bdd610c7ce634b1d8f36e87733", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "zdmitchell" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35272, + "user": { + "id": 55823474, + "login": "emmalopezcode" + }, + "type": "CR", + "created_at": "2020-12-01T17:29:48.000Z", + "active": 1, + "comment_id": 736703181 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35272, + "user": { + "id": 24502905, + "login": "bhuminson" + }, + "type": "CR", + "created_at": "2020-12-01T17:29:15.000Z", + "active": 1, + "comment_id": 736702860 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35272, + "user": { + "id": 22163939, + "login": "GrantMatejka" + }, + "type": "CR", + "created_at": "2020-12-01T17:28:28.000Z", + "active": 1, + "comment_id": 736702388 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35272, + "user": { + "id": 52104630, + "login": "masonmcelvain" + }, + "type": "CR", + "created_at": "2020-12-01T17:28:20.000Z", + "active": 1, + "comment_id": 736702305 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35272, + "user": { + "id": 55823474, + "login": "emmalopezcode" + }, + "type": "CR", + "created_at": "2020-12-01T17:29:48.000Z", + "active": 1, + "comment_id": 736703181 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35272, + "user": { + "id": 24502905, + "login": "bhuminson" + }, + "type": "CR", + "created_at": "2020-12-01T17:29:15.000Z", + "active": 1, + "comment_id": 736702860 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35272, + "user": { + "id": 22163939, + "login": "GrantMatejka" + }, + "type": "CR", + "created_at": "2020-12-01T17:28:28.000Z", + "active": 1, + "comment_id": 736702388 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35272, + "user": { + "id": 52104630, + "login": "masonmcelvain" + }, + "type": "CR", + "created_at": "2020-12-01T17:28:20.000Z", + "active": 1, + "comment_id": 736702305 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "34686acedd0bc6bdd610c7ce634b1d8f36e87733", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "34686acedd0bc6bdd610c7ce634b1d8f36e87733", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "34686acedd0bc6bdd610c7ce634b1d8f36e87733", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35319, + "state": "open", + "title": "MySQL 8: commit schema for all our DBs", + "body": "pull request dummy body", + "created_at": "2020-12-03T18:46:43.000Z", + "updated_at": "2020-12-21T22:54:28.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "mysql8--commit-schema-diff", + "sha": "0e5e559c7e3bb513a7262168ed2aadccfbb029d4", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "danielbeardsley" + }, + "cr_req": 3, + "qa_req": 0, + "status": { + "qa_req": 0, + "cr_req": 3, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35319, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "CR", + "created_at": "2020-12-21T22:54:08.000Z", + "active": 1, + "comment_id": 749241714 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35319, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "CR", + "created_at": "2020-12-21T22:54:08.000Z", + "active": 1, + "comment_id": 749241714 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35319, + "user": { + "id": 22163939, + "login": "GrantMatejka" + }, + "type": "CR", + "created_at": "2020-12-09T23:29:03.000Z", + "active": 0, + "comment_id": 742129032 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35319, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "CR", + "created_at": "2020-12-08T01:13:04.000Z", + "active": 0, + "comment_id": 740288944 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "0e5e559c7e3bb513a7262168ed2aadccfbb029d4", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "0e5e559c7e3bb513a7262168ed2aadccfbb029d4", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "0e5e559c7e3bb513a7262168ed2aadccfbb029d4", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35404, + "state": "open", + "title": "Increase margin between paragraphs but not the last paragraph", + "body": "pull request dummy body", + "created_at": "2020-12-09T01:00:19.000Z", + "updated_at": "2020-12-10T17:23:33.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "wiki-pragraph-bottom-margin", + "sha": "9ca3a99018cea4bc09abc51a9bedcda5ef52d193", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "ShadyAlzayat" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35404, + "user": { + "id": 24502905, + "login": "bhuminson" + }, + "type": "CR", + "created_at": "2020-12-09T22:23:27.000Z", + "active": 1, + "comment_id": 742101250 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35404, + "user": { + "id": 24502905, + "login": "bhuminson" + }, + "type": "CR", + "created_at": "2020-12-09T22:23:27.000Z", + "active": 1, + "comment_id": 742101250 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35404, + "user": { + "id": 24502905, + "login": "bhuminson" + }, + "type": "CR", + "created_at": "2020-12-09T04:27:54.000Z", + "active": 0, + "comment_id": 741518613 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "9ca3a99018cea4bc09abc51a9bedcda5ef52d193", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "9ca3a99018cea4bc09abc51a9bedcda5ef52d193", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "9ca3a99018cea4bc09abc51a9bedcda5ef52d193", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35426, + "state": "open", + "title": "Akeneo 4: Add cap task to migrate it", + "body": "pull request dummy body", + "created_at": "2020-12-09T22:32:59.000Z", + "updated_at": "2020-12-23T01:35:09.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "iFixit - 12/15/2020", + "due_on": "2020-12-15T08:00:00.000Z" + }, + "head": { + "ref": "akeneo-migration--automate-it", + "sha": "e3730b8d1339d0bee6d504400c8d0b17379229b4", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "danielbeardsley" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35426, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "QA", + "created_at": "2020-12-21T23:40:25.000Z", + "active": 0, + "comment_id": 749255982 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35426, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-12-21T23:29:10.000Z", + "active": 0, + "comment_id": 749252695 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35426, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-12-19T01:36:54.000Z", + "active": 0, + "comment_id": 748399125 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35426, + "user": { + "id": 589425, + "login": "sterlinghirsh" + }, + "type": "CR", + "created_at": "2020-12-11T03:52:13.000Z", + "active": 0, + "comment_id": 742950429 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35426, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-12-10T19:48:56.000Z", + "active": 0, + "comment_id": 742755688 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "e3730b8d1339d0bee6d504400c8d0b17379229b4", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "e3730b8d1339d0bee6d504400c8d0b17379229b4", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "e3730b8d1339d0bee6d504400c8d0b17379229b4", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "external_block", + "number": 35426, + "repo": "iFixit/ifixit", + "user": "k0rvusk0r4x", + "created_at": "2020-12-14T15:46:04.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35471, + "state": "open", + "title": "Shipping: Use shelf weight in calculations", + "body": "pull request dummy body", + "created_at": "2020-12-14T18:35:28.000Z", + "updated_at": "2020-12-23T04:44:55.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "iFixit - 12/15/2020", + "due_on": "2020-12-15T08:00:00.000Z" + }, + "head": { + "ref": "cart--shipping--use-shelf-weight", + "sha": "1c037fa19d5278134dd4f79a5620c987ab0c840d", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "cdcline" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35471, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-21T23:08:01.000Z", + "active": 1, + "comment_id": 749246426 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35471, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-12-19T00:09:56.000Z", + "active": 1, + "comment_id": 748383008 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35471, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-21T23:08:01.000Z", + "active": 1, + "comment_id": 749246426 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35471, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-12-19T00:09:56.000Z", + "active": 1, + "comment_id": 748383008 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35471, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-12-17T16:02:36.000Z", + "active": 0, + "comment_id": 747533124 + } + } + ], + "dev_block": [], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35471, + "user": { + "id": 22064420, + "login": "ardelato" + }, + "type": "deploy_block", + "created_at": "2020-12-18T22:38:56.000Z", + "active": 1, + "comment_id": 748353358 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "1c037fa19d5278134dd4f79a5620c987ab0c840d", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "1c037fa19d5278134dd4f79a5620c987ab0c840d", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "1c037fa19d5278134dd4f79a5620c987ab0c840d", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "CRing1", + "number": 35471, + "repo": "iFixit/ifixit", + "user": "andyg0808", + "created_at": "2020-12-16T00:36:56.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35499, + "state": "open", + "title": "Training periods: Fix out of order releases", + "body": "pull request dummy body", + "created_at": "2020-12-15T20:36:36.000Z", + "updated_at": "2020-12-17T23:06:11.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "training-periods--fix-out-of-order-releases", + "sha": "73635a3c5019c38a2af3cc8716d0b3fb2d2b5144", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "andrewjpiro" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35499, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-12-17T23:06:11.000Z", + "active": 1, + "comment_id": 747754875 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35499, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "CR", + "created_at": "2020-12-17T22:17:19.000Z", + "active": 1, + "comment_id": 747735261 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35499, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-12-17T23:06:11.000Z", + "active": 1, + "comment_id": 747754875 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35499, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "CR", + "created_at": "2020-12-17T22:17:19.000Z", + "active": 1, + "comment_id": 747735261 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35499, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-12-17T16:49:03.000Z", + "active": 0, + "comment_id": 747561960 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35499, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-12-17T00:16:19.000Z", + "active": 0, + "comment_id": 747118025 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35499, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "CR", + "created_at": "2020-12-16T21:32:08.000Z", + "active": 0, + "comment_id": 747053986 + } + } + ], + "dev_block": [], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35499, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "deploy_block", + "created_at": "2020-12-17T00:16:19.000Z", + "active": 1, + "comment_id": 747118025 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "73635a3c5019c38a2af3cc8716d0b3fb2d2b5144", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "73635a3c5019c38a2af3cc8716d0b3fb2d2b5144", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "73635a3c5019c38a2af3cc8716d0b3fb2d2b5144", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "QAing", + "number": 35499, + "repo": "iFixit/ifixit", + "user": "JRodger312", + "created_at": "2020-12-15T22:47:47.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35501, + "state": "open", + "title": "Add data endpoint and run Lighthouse on CI and deploy", + "body": "pull request dummy body", + "created_at": "2020-12-15T22:26:06.000Z", + "updated_at": "2020-12-23T01:32:13.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "ifixit - 12/29/2020", + "due_on": "2020-12-29T08:00:00.000Z" + }, + "head": { + "ref": "lighthouse--run-from-cimpler", + "sha": "3cefbe88feeb6264464c7661318f2c1469237969", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "andyg0808" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35501, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "CR", + "created_at": "2020-12-23T01:32:12.000Z", + "active": 1, + "comment_id": 749868977 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35501, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "CR", + "created_at": "2020-12-23T01:32:12.000Z", + "active": 1, + "comment_id": 749868977 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35501, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "CR", + "created_at": "2020-12-22T18:07:09.000Z", + "active": 0, + "comment_id": 749694651 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35501, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "CR", + "created_at": "2020-12-22T04:41:50.000Z", + "active": 0, + "comment_id": 749334434 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35501, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "CR", + "created_at": "2020-12-21T23:18:52.000Z", + "active": 0, + "comment_id": 749249570 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35501, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "CR", + "created_at": "2020-12-21T23:07:23.000Z", + "active": 0, + "comment_id": 749246230 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35501, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "CR", + "created_at": "2020-12-16T23:18:35.000Z", + "active": 0, + "comment_id": 747098966 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35501, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "CR", + "created_at": "2020-12-16T17:09:07.000Z", + "active": 0, + "comment_id": 746651202 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35501, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "CR", + "created_at": "2020-12-15T23:37:27.000Z", + "active": 0, + "comment_id": 745634633 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35501, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "dev_block", + "created_at": "2020-12-23T00:38:09.000Z", + "active": 1, + "comment_id": 749852498 + } + } + ], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "3cefbe88feeb6264464c7661318f2c1469237969", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "3cefbe88feeb6264464c7661318f2c1469237969", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "3cefbe88feeb6264464c7661318f2c1469237969", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35509, + "state": "open", + "title": "Stop recording edit limit errors to Errbit", + "body": "pull request dummy body", + "created_at": "2020-12-16T18:56:16.000Z", + "updated_at": "2020-12-23T00:20:33.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "ifixit - 12/29/2020", + "due_on": "2020-12-29T08:00:00.000Z" + }, + "head": { + "ref": "Reputation-Limits--stop-reporting-errbit", + "sha": "da71dabf590388eea6844460386962c4a34e74af", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "bhuminson" + }, + "cr_req": 2, + "qa_req": 0, + "status": { + "qa_req": 0, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35509, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-21T19:27:40.000Z", + "active": 0, + "comment_id": 749155116 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35509, + "user": { + "id": 22163939, + "login": "GrantMatejka" + }, + "type": "CR", + "created_at": "2020-12-16T22:26:57.000Z", + "active": 0, + "comment_id": 747078014 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35509, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-16T21:58:10.000Z", + "active": 0, + "comment_id": 747065792 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35509, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-16T19:44:19.000Z", + "active": 0, + "comment_id": 746902826 + } + } + ], + "dev_block": [], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35509, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "deploy_block", + "created_at": "2020-12-22T00:08:48.000Z", + "active": 1, + "comment_id": 749264096 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35509, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "deploy_block", + "created_at": "2020-12-21T19:27:40.000Z", + "active": 1, + "comment_id": 749155116 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "da71dabf590388eea6844460386962c4a34e74af", + "target_url": "https://wwww.example.com", + "description": "Build Failure", + "state": "failure", + "context": "jest" + } + }, + { + "data": { + "sha": "da71dabf590388eea6844460386962c4a34e74af", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "da71dabf590388eea6844460386962c4a34e74af", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35517, + "state": "open", + "title": "Grafana: Update Guide Drafts and Release Versions dashboard per customer requests", + "body": "pull request dummy body", + "created_at": "2020-12-16T22:54:13.000Z", + "updated_at": "2020-12-22T22:43:23.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "grafana--update-guide-release-versions-dashboard", + "sha": "37c8407dd115aa6fcf1b9904e929b7f68a3d446f", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "sivadnor" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35517, + "user": { + "id": 22064420, + "login": "ardelato" + }, + "type": "QA", + "created_at": "2020-12-22T22:42:11.000Z", + "active": 1, + "comment_id": 749815161 + } + } + ], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35517, + "user": { + "id": 1248182, + "login": "cdcline" + }, + "type": "CR", + "created_at": "2020-12-18T19:38:44.000Z", + "active": 1, + "comment_id": 748283348 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35517, + "user": { + "id": 4808, + "login": "scbarber" + }, + "type": "CR", + "created_at": "2020-12-16T23:50:06.000Z", + "active": 1, + "comment_id": 747109662 + } + } + ], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35517, + "user": { + "id": 22064420, + "login": "ardelato" + }, + "type": "QA", + "created_at": "2020-12-22T22:42:11.000Z", + "active": 1, + "comment_id": 749815161 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35517, + "user": { + "id": 1248182, + "login": "cdcline" + }, + "type": "CR", + "created_at": "2020-12-18T19:38:44.000Z", + "active": 1, + "comment_id": 748283348 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35517, + "user": { + "id": 4808, + "login": "scbarber" + }, + "type": "CR", + "created_at": "2020-12-16T23:50:06.000Z", + "active": 1, + "comment_id": 747109662 + } + } + ], + "dev_block": [], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35517, + "user": { + "id": 22064420, + "login": "ardelato" + }, + "type": "deploy_block", + "created_at": "2020-12-22T22:42:11.000Z", + "active": 1, + "comment_id": 749815161 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "37c8407dd115aa6fcf1b9904e929b7f68a3d446f", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "37c8407dd115aa6fcf1b9904e929b7f68a3d446f", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "37c8407dd115aa6fcf1b9904e929b7f68a3d446f", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35520, + "state": "open", + "title": "Grafana: Add a custom User Views dashboard for customer", + "body": "pull request dummy body", + "created_at": "2020-12-16T23:57:43.000Z", + "updated_at": "2020-12-23T00:44:30.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "Grafana--add-custom-user-views-dashboard", + "sha": "705f8729ae8c27bc873437b30593506bafb492a7", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "sivadnor" + }, + "cr_req": 1, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 1, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35520, + "user": { + "id": 22064420, + "login": "ardelato" + }, + "type": "QA", + "created_at": "2020-12-23T00:41:54.000Z", + "active": 1, + "comment_id": 749853461 + } + } + ], + "CR": [], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35520, + "user": { + "id": 22064420, + "login": "ardelato" + }, + "type": "QA", + "created_at": "2020-12-23T00:41:54.000Z", + "active": 1, + "comment_id": 749853461 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35520, + "user": { + "id": 1248182, + "login": "cdcline" + }, + "type": "CR", + "created_at": "2020-12-18T19:37:58.000Z", + "active": 0, + "comment_id": 748283005 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "705f8729ae8c27bc873437b30593506bafb492a7", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "705f8729ae8c27bc873437b30593506bafb492a7", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "705f8729ae8c27bc873437b30593506bafb492a7", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35524, + "state": "open", + "title": "PHP-Webdriver: Upgrade for Chrome tests only", + "body": "pull request dummy body", + "created_at": "2020-12-17T01:30:16.000Z", + "updated_at": "2020-12-22T18:19:47.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "Dozuki - Jan 5, 2020", + "due_on": "2021-01-05T08:00:00.000Z" + }, + "head": { + "ref": "php-webdriver--upgrade-for-chrome", + "sha": "209768391f2a8042341fe9973582ae4bff8275ff", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "participants": [ + "BaseInfinity", + "other", + "another" + ], + "user": { + "login": "BaseInfinity" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35524, + "user": { + "id": 29667789, + "login": "mlahargou" + }, + "type": "QA", + "created_at": "2020-12-17T18:25:37.000Z", + "active": 0, + "comment_id": 747615954 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35524, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "CR", + "created_at": "2020-12-19T00:40:16.000Z", + "active": 0, + "comment_id": 748389739 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35524, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-12-17T16:21:15.000Z", + "active": 0, + "comment_id": 747545770 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35524, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "dev_block", + "created_at": "2020-12-19T00:40:16.000Z", + "active": 1, + "comment_id": 748389739 + } + } + ], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35524, + "user": { + "id": 29667789, + "login": "mlahargou" + }, + "type": "deploy_block", + "created_at": "2020-12-17T18:26:12.000Z", + "active": 1, + "comment_id": 747616236 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35524, + "user": { + "id": 1424113, + "login": "BaseInfinity" + }, + "type": "deploy_block", + "created_at": "2020-12-17T02:00:17.000Z", + "active": 1, + "comment_id": 747154592 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "209768391f2a8042341fe9973582ae4bff8275ff", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "209768391f2a8042341fe9973582ae4bff8275ff", + "target_url": "https://wwww.example.com", + "description": "Build failure", + "state": "failure", + "context": "phpunit" + } + }, + { + "data": { + "sha": "209768391f2a8042341fe9973582ae4bff8275ff", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35529, + "state": "open", + "title": "Approval Process View: Fix memory usage with large sites", + "body": "pull request dummy body", + "created_at": "2020-12-17T17:58:27.000Z", + "updated_at": "2020-12-22T15:44:38.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "approvals--fix-memory-usage-on-view", + "sha": "8665c60cc240c296c7dec6e01c4aef0f658f6910", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "andrewjpiro" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35529, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-12-17T18:54:16.000Z", + "active": 1, + "comment_id": 747631597 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35529, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2020-12-17T18:54:16.000Z", + "active": 1, + "comment_id": 747631597 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35529, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "dev_block", + "created_at": "2020-12-18T19:34:18.000Z", + "active": 1, + "comment_id": 748281488 + } + } + ], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "8665c60cc240c296c7dec6e01c4aef0f658f6910", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "8665c60cc240c296c7dec6e01c4aef0f658f6910", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "8665c60cc240c296c7dec6e01c4aef0f658f6910", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "QAing", + "number": 35529, + "repo": "iFixit/ifixit", + "user": "JRodger312", + "created_at": "2020-12-18T18:45:53.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35531, + "state": "open", + "title": "DB Replica: Update hostnames", + "body": "pull request dummy body", + "created_at": "2020-12-17T20:03:41.000Z", + "updated_at": "2020-12-21T22:41:06.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "replcia-db--use-new-hostname", + "sha": "4398ce1c63f4796ac6af8e373e4cdfa9d50a37a8", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "participants": [ + "evannoronha", + "someone else" + ], + "user": { + "login": "evannoronha" + }, + "cr_req": 3, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 3, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35531, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-18T22:54:09.000Z", + "active": 1, + "comment_id": 748357941 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35531, + "user": { + "id": 52104630, + "login": "masonmcelvain" + }, + "type": "CR", + "created_at": "2020-12-18T22:08:56.000Z", + "active": 1, + "comment_id": 748343534 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35531, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-18T22:54:09.000Z", + "active": 1, + "comment_id": 748357941 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35531, + "user": { + "id": 52104630, + "login": "masonmcelvain" + }, + "type": "CR", + "created_at": "2020-12-18T22:08:56.000Z", + "active": 1, + "comment_id": 748343534 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35531, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "dev_block", + "created_at": "2020-12-21T21:50:14.000Z", + "active": 1, + "comment_id": 749215634 + } + } + ], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "4398ce1c63f4796ac6af8e373e4cdfa9d50a37a8", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "4398ce1c63f4796ac6af8e373e4cdfa9d50a37a8", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "4398ce1c63f4796ac6af8e373e4cdfa9d50a37a8", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35536, + "state": "open", + "title": "WDTeamEditTest: Add proper configs and unskip", + "body": "pull request dummy body", + "created_at": "2020-12-17T22:44:44.000Z", + "updated_at": "2020-12-22T23:31:18.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "tests--fix-up-team-edit-test", + "sha": "29a559ea6a8e0932745a9f137795be29ba6bc283", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "mlahargou" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35536, + "user": { + "id": 29667789, + "login": "mlahargou" + }, + "type": "QA", + "created_at": "2020-12-22T20:05:23.000Z", + "active": 1, + "comment_id": 749750929 + } + } + ], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35536, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-22T23:31:18.000Z", + "active": 1, + "comment_id": 749835141 + } + } + ], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35536, + "user": { + "id": 29667789, + "login": "mlahargou" + }, + "type": "QA", + "created_at": "2020-12-22T20:05:23.000Z", + "active": 1, + "comment_id": 749750929 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35536, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-22T23:31:18.000Z", + "active": 1, + "comment_id": 749835141 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35536, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-18T22:48:42.000Z", + "active": 0, + "comment_id": 748356261 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35536, + "user": { + "id": 1248182, + "login": "cdcline" + }, + "type": "CR", + "created_at": "2020-12-18T19:54:49.000Z", + "active": 0, + "comment_id": 748290015 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "29a559ea6a8e0932745a9f137795be29ba6bc283", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "29a559ea6a8e0932745a9f137795be29ba6bc283", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "29a559ea6a8e0932745a9f137795be29ba6bc283", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35543, + "state": "open", + "title": "Stores: extract list provider", + "body": "pull request dummy body", + "created_at": "2020-12-18T00:48:38.000Z", + "updated_at": "2020-12-22T19:11:50.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "stores--extract-list-provider", + "sha": "39f17dd5b7401541bbb98a302787324c3a1b3d3f", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "sctice" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35543, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "CR", + "created_at": "2020-12-21T23:00:30.000Z", + "active": 1, + "comment_id": 749244160 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35543, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "CR", + "created_at": "2020-12-21T23:00:30.000Z", + "active": 1, + "comment_id": 749244160 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35543, + "user": { + "id": 589425, + "login": "sterlinghirsh" + }, + "type": "CR", + "created_at": "2020-12-18T09:55:02.000Z", + "active": 0, + "comment_id": 747994235 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "39f17dd5b7401541bbb98a302787324c3a1b3d3f", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "39f17dd5b7401541bbb98a302787324c3a1b3d3f", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "39f17dd5b7401541bbb98a302787324c3a1b3d3f", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35551, + "state": "open", + "title": "Training Period: Round to beginning of the last day.", + "body": "pull request dummy body", + "created_at": "2020-12-18T18:32:37.000Z", + "updated_at": "2020-12-21T22:50:56.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "training-period--round-to-midnight", + "sha": "a1eba637843a2a6880c9320c710790ed2b830b65", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "hackalot805" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35551, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "CR", + "created_at": "2020-12-18T19:23:11.000Z", + "active": 0, + "comment_id": 748276171 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35551, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "dev_block", + "created_at": "2020-12-21T22:50:56.000Z", + "active": 1, + "comment_id": 749240790 + } + } + ], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35551, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "deploy_block", + "created_at": "2020-12-18T19:23:11.000Z", + "active": 1, + "comment_id": 748276171 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "a1eba637843a2a6880c9320c710790ed2b830b65", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "a1eba637843a2a6880c9320c710790ed2b830b65", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "a1eba637843a2a6880c9320c710790ed2b830b65", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35553, + "state": "open", + "title": "Add ability to upload documents to data capture", + "body": "pull request dummy body", + "created_at": "2020-12-18T18:48:59.000Z", + "updated_at": "2020-12-22T18:35:50.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "dozuki-2334-data-capture-documents", + "sha": "0d076e75e519f6fa0ca79bc585dc633ccbd182d1", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "rjmccluskey" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35553, + "user": { + "id": 806152, + "login": "davidrans" + }, + "type": "CR", + "created_at": "2020-12-22T16:07:22.000Z", + "active": 1, + "comment_id": 749622483 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35553, + "user": { + "id": 806152, + "login": "davidrans" + }, + "type": "CR", + "created_at": "2020-12-22T16:07:22.000Z", + "active": 1, + "comment_id": 749622483 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "0d076e75e519f6fa0ca79bc585dc633ccbd182d1", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "0d076e75e519f6fa0ca79bc585dc633ccbd182d1", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "0d076e75e519f6fa0ca79bc585dc633ccbd182d1", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35554, + "state": "open", + "title": "Courses: show guide completion panel when release versions disabled", + "body": "pull request dummy body", + "created_at": "2020-12-18T19:06:39.000Z", + "updated_at": "2020-12-22T23:03:39.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "courses--fix-guide-complete-release-versions-disabled", + "sha": "1ffab577e637c5640ee34b7b125e101c07172b7e", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "bcooper94" + }, + "cr_req": 1, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 1, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35554, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "CR", + "created_at": "2020-12-18T19:31:16.000Z", + "active": 1, + "comment_id": 748280136 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35554, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "CR", + "created_at": "2020-12-18T19:31:16.000Z", + "active": 1, + "comment_id": 748280136 + } + } + ], + "dev_block": [], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35554, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "deploy_block", + "created_at": "2020-12-18T19:31:16.000Z", + "active": 1, + "comment_id": 748280136 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "1ffab577e637c5640ee34b7b125e101c07172b7e", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "1ffab577e637c5640ee34b7b125e101c07172b7e", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "1ffab577e637c5640ee34b7b125e101c07172b7e", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "0 bug", + "number": 35554, + "repo": "iFixit/ifixit", + "user": "bcooper94", + "created_at": "2020-12-18T19:06:39.000Z" + }, + { + "title": "r-Dozuki", + "number": 35554, + "repo": "iFixit/ifixit", + "user": "bcooper94", + "created_at": "2020-12-18T19:06:39.000Z" + }, + { + "title": "r-no-notes", + "number": 35554, + "repo": "iFixit/ifixit", + "user": "bcooper94", + "created_at": "2020-12-18T19:06:39.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35562, + "state": "open", + "title": "Shopfiy Orders API: Read and Save", + "body": "pull request dummy body", + "created_at": "2020-12-18T23:21:23.000Z", + "updated_at": "2020-12-23T01:49:44.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "ifixit - 12/29/2020", + "due_on": "2020-12-29T08:00:00.000Z" + }, + "head": { + "ref": "shopify-orders-api--read-and-save", + "sha": "b376ca6c42559055a6733ba77dd7ec8d49b76741", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "zdmitchell" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35562, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "QA", + "created_at": "2020-12-23T01:49:44.000Z", + "active": 1, + "comment_id": 749875015 + } + } + ], + "CR": [], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35562, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "QA", + "created_at": "2020-12-23T01:49:44.000Z", + "active": 1, + "comment_id": 749875015 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35562, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "QA", + "created_at": "2020-12-23T01:23:20.000Z", + "active": 0, + "comment_id": 749865571 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35562, + "user": { + "id": 51836911, + "login": "zdmitchell" + }, + "type": "QA", + "created_at": "2020-12-22T21:48:17.000Z", + "active": 0, + "comment_id": 749791703 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35562, + "user": { + "id": 72166715, + "login": "erinemay" + }, + "type": "QA", + "created_at": "2020-12-22T18:41:54.000Z", + "active": 0, + "comment_id": 749712466 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35562, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-22T18:33:57.000Z", + "active": 0, + "comment_id": 749709247 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35562, + "user": { + "id": 43477081, + "login": "jyee27" + }, + "type": "CR", + "created_at": "2020-12-21T23:00:25.000Z", + "active": 0, + "comment_id": 749244129 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35562, + "user": { + "id": 589425, + "login": "sterlinghirsh" + }, + "type": "CR", + "created_at": "2020-12-19T01:28:15.000Z", + "active": 0, + "comment_id": 748397993 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "b376ca6c42559055a6733ba77dd7ec8d49b76741", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "b376ca6c42559055a6733ba77dd7ec8d49b76741", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "b376ca6c42559055a6733ba77dd7ec8d49b76741", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35572, + "state": "open", + "title": "Grafana: Add a custom comments dashboard", + "body": "pull request dummy body", + "created_at": "2020-12-21T17:45:10.000Z", + "updated_at": "2020-12-23T00:12:20.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "grafana--add-custom-comments-dashboard", + "sha": "bb29dcda758feb3df2c34b7ac7dddc9fc761eb50", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "sivadnor" + }, + "cr_req": 0, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 0, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35572, + "user": { + "id": 58952979, + "login": "batbattur" + }, + "type": "QA", + "created_at": "2020-12-23T00:12:13.000Z", + "active": 1, + "comment_id": 749845465 + } + } + ], + "CR": [], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35572, + "user": { + "id": 58952979, + "login": "batbattur" + }, + "type": "QA", + "created_at": "2020-12-23T00:12:13.000Z", + "active": 1, + "comment_id": 749845465 + } + } + ], + "allCR": [], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35572, + "user": { + "id": 58952979, + "login": "batbattur" + }, + "type": "dev_block", + "created_at": "2020-12-22T22:17:00.000Z", + "active": 1, + "comment_id": 749804131 + } + } + ], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "bb29dcda758feb3df2c34b7ac7dddc9fc761eb50", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "bb29dcda758feb3df2c34b7ac7dddc9fc761eb50", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "bb29dcda758feb3df2c34b7ac7dddc9fc761eb50", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35577, + "state": "open", + "title": "Polish: Increase dropdown hover contrast", + "body": "pull request dummy body", + "created_at": "2020-12-21T20:39:08.000Z", + "updated_at": "2020-12-22T01:24:10.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "polish--better-notification-dropdown-contrast", + "sha": "da390325a89081f9ddc8d224cdc1ed4802c71c2e", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "ianrohde" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35577, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "CR", + "created_at": "2020-12-22T00:28:18.000Z", + "active": 1, + "comment_id": 749270537 + } + } + ], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35577, + "user": { + "id": 129007, + "login": "kwiens" + }, + "type": "QA", + "created_at": "2020-12-21T22:55:17.000Z", + "active": 0, + "comment_id": 749242143 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35577, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "CR", + "created_at": "2020-12-22T00:28:18.000Z", + "active": 1, + "comment_id": 749270537 + } + }, + { + "data": { + "repo": "iFixit/ifixit", + "number": 35577, + "user": { + "id": 24502905, + "login": "bhuminson" + }, + "type": "CR", + "created_at": "2020-12-21T21:54:18.000Z", + "active": 0, + "comment_id": 749217587 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35577, + "user": { + "id": 8008311, + "login": "rjmccluskey" + }, + "type": "dev_block", + "created_at": "2020-12-21T22:57:01.000Z", + "active": 1, + "comment_id": 749242937 + } + } + ], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "da390325a89081f9ddc8d224cdc1ed4802c71c2e", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "da390325a89081f9ddc8d224cdc1ed4802c71c2e", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "da390325a89081f9ddc8d224cdc1ed4802c71c2e", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "polish", + "number": 35577, + "repo": "iFixit/ifixit", + "user": "ianrohde", + "created_at": "2020-12-21T20:39:08.000Z" + } + ] + }, + { + "repo": "iFixit/ifixit", + "number": 35583, + "state": "open", + "title": "Yelp: Regulate type of Response", + "body": "pull request dummy body", + "created_at": "2020-12-21T22:30:01.000Z", + "updated_at": "2020-12-22T00:39:25.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "ifixit - 12/29/2020", + "due_on": "2020-12-29T08:00:00.000Z" + }, + "head": { + "ref": "Yelp--dont-500-on-bad-response", + "sha": "92b9c886f55325033c642c5496c45d967bb3b742", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "emmalopezcode" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35583, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "QA", + "created_at": "2020-12-22T00:06:48.000Z", + "active": 1, + "comment_id": 749263306 + } + } + ], + "CR": [], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35583, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "QA", + "created_at": "2020-12-22T00:06:48.000Z", + "active": 1, + "comment_id": 749263306 + } + } + ], + "allCR": [], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "92b9c886f55325033c642c5496c45d967bb3b742", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "92b9c886f55325033c642c5496c45d967bb3b742", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "92b9c886f55325033c642c5496c45d967bb3b742", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35587, + "state": "open", + "title": "[DRAFT] Newsletter: create promo page", + "body": "pull request dummy body", + "created_at": "2020-12-22T00:26:39.000Z", + "updated_at": "2020-12-23T02:52:54.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "ifixit - 12/29/2020", + "due_on": "2020-12-29T08:00:00.000Z" + }, + "head": { + "ref": "Newsletter--create-promo-page", + "sha": "b7032c50433ddf52a1aa839c613a4c0f23001c3c", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "Michelle5102" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35587, + "user": { + "id": 806152, + "login": "davidrans" + }, + "type": "dev_block", + "created_at": "2020-12-22T15:14:11.000Z", + "active": 1, + "comment_id": 749591094 + } + } + ], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "b7032c50433ddf52a1aa839c613a4c0f23001c3c", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "b7032c50433ddf52a1aa839c613a4c0f23001c3c", + "target_url": "https://wwww.example.com", + "description": "Build failure", + "state": "failure", + "context": "phpunit" + } + }, + { + "data": { + "sha": "b7032c50433ddf52a1aa839c613a4c0f23001c3c", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 45587, + "state": "open", + "title": "[DRAFT] Newsletter: create promo page", + "draft": true, + "body": "pull request dummy body", + "created_at": "2020-12-22T00:26:39.000Z", + "updated_at": "2020-12-23T02:52:54.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "ifixit - 12/29/2020", + "due_on": "2020-12-29T08:00:00.000Z" + }, + "head": { + "ref": "Newsletter--create-promo-page", + "sha": "b7032c50433ddf52a1aa839c613a4c0f23001c3c", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "Michelle5102" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35587, + "user": { + "id": 806152, + "login": "davidrans" + }, + "type": "dev_block", + "created_at": "2020-12-22T15:14:11.000Z", + "active": 1, + "comment_id": 749591094 + } + } + ], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "b7032c50433ddf52a1aa839c613a4c0f23001c3c", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "b7032c50433ddf52a1aa839c613a4c0f23001c3c", + "target_url": "https://wwww.example.com", + "description": "Build failure", + "state": "failure", + "context": "phpunit" + } + }, + { + "data": { + "sha": "b7032c50433ddf52a1aa839c613a4c0f23001c3c", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35590, + "state": "open", + "title": "Exec: discourage new files in Exec/ dir", + "body": "pull request dummy body", + "created_at": "2020-12-22T17:33:20.000Z", + "updated_at": "2020-12-22T20:09:56.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "exec-scripts--add-antipattern-test", + "sha": "403403dea080615fcc4cf9e0a54dd6d5c5e246c8", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "danielbeardsley" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35590, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "QA", + "created_at": "2020-12-22T19:18:28.000Z", + "active": 1, + "comment_id": 749727994 + } + } + ], + "CR": [], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35590, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "QA", + "created_at": "2020-12-22T19:18:28.000Z", + "active": 1, + "comment_id": 749727994 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35590, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "CR", + "created_at": "2020-12-22T19:03:22.000Z", + "active": 0, + "comment_id": 749721423 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35590, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "dev_block", + "created_at": "2020-12-22T20:09:56.000Z", + "active": 1, + "comment_id": 749752880 + } + } + ], + "deploy_block": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35590, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "deploy_block", + "created_at": "2020-12-22T19:03:22.000Z", + "active": 1, + "comment_id": 749721423 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "403403dea080615fcc4cf9e0a54dd6d5c5e246c8", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "403403dea080615fcc4cf9e0a54dd6d5c5e246c8", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "403403dea080615fcc4cf9e0a54dd6d5c5e246c8", + "target_url": "https://wwww.example.com", + "description": "Build failure", + "state": "failure", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35595, + "state": "open", + "title": "Featured Product Page: Fix product schema", + "body": "pull request dummy body", + "created_at": "2020-12-22T22:12:19.000Z", + "updated_at": "2020-12-23T00:47:02.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "ifixit - 12/29/2020", + "due_on": "2020-12-29T08:00:00.000Z" + }, + "head": { + "ref": "fpp--fix-product-schema", + "sha": "ed67fa5a4e9244126463fab53bb7ad93847165e1", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "jyee27" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "ed67fa5a4e9244126463fab53bb7ad93847165e1", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "ed67fa5a4e9244126463fab53bb7ad93847165e1", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "ed67fa5a4e9244126463fab53bb7ad93847165e1", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 35597, + "state": "open", + "title": "WDAnswersSmokeTest: Deflake and robustify", + "body": "pull request dummy body", + "created_at": "2020-12-22T22:17:44.000Z", + "updated_at": "2020-12-23T01:25:37.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "tests--deflake-answers-smoke-test", + "sha": "c91d1c889d84c5f2f861c53c960df6e4098a9747", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "mlahargou" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35597, + "user": { + "id": 58952979, + "login": "batbattur" + }, + "type": "QA", + "created_at": "2020-12-23T00:27:54.000Z", + "active": 1, + "comment_id": 749849643 + } + } + ], + "CR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35597, + "user": { + "id": 24502905, + "login": "bhuminson" + }, + "type": "CR", + "created_at": "2020-12-23T01:25:36.000Z", + "active": 1, + "comment_id": 749866546 + } + } + ], + "allQA": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35597, + "user": { + "id": 58952979, + "login": "batbattur" + }, + "type": "QA", + "created_at": "2020-12-23T00:27:54.000Z", + "active": 1, + "comment_id": 749849643 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/ifixit", + "number": 35597, + "user": { + "id": 24502905, + "login": "bhuminson" + }, + "type": "CR", + "created_at": "2020-12-23T01:25:36.000Z", + "active": 1, + "comment_id": 749866546 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "c91d1c889d84c5f2f861c53c960df6e4098a9747", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "jest" + } + }, + { + "data": { + "sha": "c91d1c889d84c5f2f861c53c960df6e4098a9747", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + }, + { + "data": { + "sha": "c91d1c889d84c5f2f861c53c960df6e4098a9747", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "psalm" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/server-templates", + "number": 2284, + "state": "open", + "title": "Execute the runIdempotentOnly script", + "body": "pull request dummy body", + "created_at": "2019-07-31T20:44:03.000Z", + "updated_at": "2020-11-20T10:03:20.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "on-prem--run-idempotent-only-on-deploy", + "sha": "d35ea773eac5e112b7a768f7e46b0eb22769638c", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "scbarber" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2284, + "user": { + "id": 4808, + "login": "scbarber" + }, + "type": "QA", + "created_at": "2019-08-01T22:36:35.000Z", + "active": 1, + "comment_id": 517482379 + } + } + ], + "CR": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2284, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "CR", + "created_at": "2019-07-31T20:53:03.000Z", + "active": 1, + "comment_id": 517016668 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2284, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "CR", + "created_at": "2019-07-31T20:50:28.000Z", + "active": 1, + "comment_id": 517015857 + } + } + ], + "allQA": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2284, + "user": { + "id": 4808, + "login": "scbarber" + }, + "type": "QA", + "created_at": "2019-08-01T22:36:35.000Z", + "active": 1, + "comment_id": 517482379 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2284, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "CR", + "created_at": "2019-07-31T20:53:03.000Z", + "active": 1, + "comment_id": 517016668 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2284, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "CR", + "created_at": "2019-07-31T20:50:28.000Z", + "active": 1, + "comment_id": 517015857 + } + } + ], + "dev_block": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2284, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "dev_block", + "created_at": "2019-08-02T20:12:08.000Z", + "active": 1, + "comment_id": 517830078 + } + } + ], + "deploy_block": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2284, + "user": { + "id": 4808, + "login": "scbarber" + }, + "type": "deploy_block", + "created_at": "2019-08-01T23:16:10.000Z", + "active": 1, + "comment_id": 517490598 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "d35ea773eac5e112b7a768f7e46b0eb22769638c", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "default" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "Cryogenic Storage", + "number": 2284, + "repo": "iFixit/server-templates", + "user": "kwiens", + "created_at": "2020-01-09T22:49:32.000Z" + }, + { + "title": "external_block", + "number": 2284, + "repo": "iFixit/server-templates", + "user": "addison-grant", + "created_at": "2019-08-01T18:40:36.000Z" + }, + { + "title": "r-Dozuki", + "number": 2284, + "repo": "iFixit/server-templates", + "user": "scbarber", + "created_at": "2019-07-31T20:44:03.000Z" + } + ] + }, + { + "repo": "iFixit/server-templates", + "number": 2337, + "state": "open", + "title": "Update Replicated API version", + "body": "pull request dummy body", + "created_at": "2019-10-14T18:53:17.000Z", + "updated_at": "2020-11-20T10:03:20.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "replicated-version-update", + "sha": "c05499c78c6e06032dc1c25e2968b8d3a018cc14", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "djmetzle" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2337, + "user": { + "id": 1424113, + "login": "BaseInfinity" + }, + "type": "CR", + "created_at": "2019-10-18T20:11:44.000Z", + "active": 1, + "comment_id": 543919682 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2337, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2019-10-14T22:15:07.000Z", + "active": 1, + "comment_id": 541954022 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2337, + "user": { + "id": 1424113, + "login": "BaseInfinity" + }, + "type": "CR", + "created_at": "2019-10-18T20:11:44.000Z", + "active": 1, + "comment_id": 543919682 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2337, + "user": { + "id": 5891146, + "login": "bcooper94" + }, + "type": "CR", + "created_at": "2019-10-14T22:15:07.000Z", + "active": 1, + "comment_id": 541954022 + } + } + ], + "dev_block": [], + "deploy_block": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2337, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "deploy_block", + "created_at": "2019-10-14T22:36:45.000Z", + "active": 1, + "comment_id": 541961199 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "c05499c78c6e06032dc1c25e2968b8d3a018cc14", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "default" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "cloudprem", + "number": 2337, + "repo": "iFixit/server-templates", + "user": "simX", + "created_at": "2019-10-23T23:23:26.000Z" + }, + { + "title": "Cryogenic Storage", + "number": 2337, + "repo": "iFixit/server-templates", + "user": "kwiens", + "created_at": "2020-02-26T00:38:44.000Z" + }, + { + "title": "external_block", + "number": 2337, + "repo": "iFixit/server-templates", + "user": "addison-grant", + "created_at": "2019-10-14T23:36:56.000Z" + }, + { + "title": "onprem", + "number": 2337, + "repo": "iFixit/server-templates", + "user": "djmetzle", + "created_at": "2020-07-21T20:49:57.000Z" + } + ] + }, + { + "repo": "iFixit/server-templates", + "number": 2835, + "state": "open", + "title": "Bumped repository version to 8 from 7 in mysql scripts", + "body": "pull request dummy body", + "created_at": "2020-11-09T20:15:35.000Z", + "updated_at": "2020-11-20T10:03:20.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "mryan/RHEL8-repos", + "sha": "6b5a2975a6ab853a3b809b7212ce747cf116ed10", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "renovate[bot]" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [], + "dev_block": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2835, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "dev_block", + "created_at": "2020-11-09T20:54:11.000Z", + "active": 1, + "comment_id": 724272110 + } + } + ], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "6b5a2975a6ab853a3b809b7212ce747cf116ed10", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "infrastructure", + "number": 2835, + "repo": "iFixit/server-templates", + "user": "djmetzle", + "created_at": "2020-11-09T20:45:39.000Z" + }, + { + "title": "onprem", + "number": 2835, + "repo": "iFixit/server-templates", + "user": "djmetzle", + "created_at": "2020-11-09T20:45:39.000Z" + }, + { + "title": "production", + "number": 2835, + "repo": "iFixit/server-templates", + "user": "djmetzle", + "created_at": "2020-11-09T20:45:39.000Z" + }, + { + "title": "storage", + "number": 2835, + "repo": "iFixit/server-templates", + "user": "djmetzle", + "created_at": "2020-11-09T20:45:40.000Z" + }, + { + "title": "upgrades", + "number": 2835, + "repo": "iFixit/server-templates", + "user": "djmetzle", + "created_at": "2020-11-09T20:45:40.000Z" + } + ] + }, + { + "repo": "iFixit/server-templates", + "number": 2848, + "state": "open", + "title": "webpack-dev: Move to Code dir", + "body": "pull request dummy body", + "created_at": "2020-11-17T20:11:00.000Z", + "updated_at": "2020-12-22T19:26:24.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "iFixit - 12/01/2020", + "due_on": null + }, + "head": { + "ref": "webpack-dev-server--upgrade-node-version", + "sha": "152b1a9dc7556e9deb7b4bd4c9205a0067e41dcd", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "andyg0808" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2848, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "CR", + "created_at": "2020-11-18T01:17:54.000Z", + "active": 1, + "comment_id": 729313397 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2848, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "CR", + "created_at": "2020-11-17T21:25:00.000Z", + "active": 1, + "comment_id": 729223188 + } + } + ], + "allQA": [], + "allCR": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2848, + "user": { + "id": 26855, + "login": "danielbeardsley" + }, + "type": "CR", + "created_at": "2020-11-18T01:17:54.000Z", + "active": 1, + "comment_id": 729313397 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2848, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "CR", + "created_at": "2020-11-17T21:25:00.000Z", + "active": 1, + "comment_id": 729223188 + } + } + ], + "dev_block": [], + "deploy_block": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2848, + "user": { + "id": 10889879, + "login": "addison-grant" + }, + "type": "deploy_block", + "created_at": "2020-11-21T01:09:10.000Z", + "active": 1, + "comment_id": 731481374 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2848, + "user": { + "id": 10889879, + "login": "addison-grant" + }, + "type": "deploy_block", + "created_at": "2020-11-21T00:56:45.000Z", + "active": 1, + "comment_id": 731479173 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2848, + "user": { + "id": 1283490, + "login": "andyg0808" + }, + "type": "deploy_block", + "created_at": "2020-11-17T20:12:16.000Z", + "active": 1, + "comment_id": 729174176 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "152b1a9dc7556e9deb7b4bd4c9205a0067e41dcd", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "external_block", + "number": 2848, + "repo": "iFixit/server-templates", + "user": "addison-grant", + "created_at": "2020-11-21T01:09:20.000Z" + } + ] + }, + { + "repo": "iFixit/server-templates", + "number": 2849, + "state": "open", + "title": "README update", + "body": "pull request dummy body", + "created_at": "2020-11-18T00:48:21.000Z", + "updated_at": "2020-11-24T22:59:35.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "readme-update", + "sha": "6cb1dec7f93c85498883d0d8a816bfe2ce3deaaf", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "ardelato" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2849, + "user": { + "id": 33426643, + "login": "k0rvusk0r4x" + }, + "type": "QA", + "created_at": "2020-11-24T23:09:17.000Z", + "active": 1, + "comment_id": 733285075 + } + } + ], + "CR": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2849, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "CR", + "created_at": "2020-11-25T16:08:45.000Z", + "active": 1, + "comment_id": 733801233 + } + } + ], + "allQA": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2849, + "user": { + "id": 33426643, + "login": "k0rvusk0r4x" + }, + "type": "QA", + "created_at": "2020-11-24T23:09:17.000Z", + "active": 1, + "comment_id": 733285075 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2849, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "CR", + "created_at": "2020-11-25T16:08:45.000Z", + "active": 1, + "comment_id": 733801233 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2849, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "CR", + "created_at": "2020-11-23T19:21:19.000Z", + "active": 0, + "comment_id": 732372491 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2849, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "CR", + "created_at": "2020-11-19T01:32:42.000Z", + "active": 0, + "comment_id": 730064943 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2849, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "CR", + "created_at": "2020-11-18T16:17:51.000Z", + "active": 0, + "comment_id": 729787888 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2849, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "CR", + "created_at": "2020-11-18T01:29:42.000Z", + "active": 0, + "comment_id": 729317289 + } + } + ], + "dev_block": [], + "deploy_block": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2849, + "user": { + "id": 22064420, + "login": "ardelato" + }, + "type": "deploy_block", + "created_at": "2020-11-19T01:23:44.000Z", + "active": 1, + "comment_id": 730062052 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2849, + "user": { + "id": 13455801, + "login": "hackalot805" + }, + "type": "deploy_block", + "created_at": "2020-11-18T16:17:51.000Z", + "active": 1, + "comment_id": 729787888 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "6cb1dec7f93c85498883d0d8a816bfe2ce3deaaf", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/server-templates", + "number": 2864, + "state": "open", + "title": "Upgrade MySQL to 8 in onprem", + "body": "pull request dummy body", + "created_at": "2020-12-01T22:57:06.000Z", + "updated_at": "2020-12-10T22:05:19.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "mryan/mysql-8-upgrade", + "sha": "47af6afacba9a48302bc28b392a18ff7e09cd498", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "dependabot[bot]" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [], + "dev_block": [], + "deploy_block": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2864, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "deploy_block", + "created_at": "2020-12-01T23:27:55.000Z", + "active": 1, + "comment_id": 736886386 + } + } + ], + "commit_statuses": [ + { + "data": { + "sha": "47af6afacba9a48302bc28b392a18ff7e09cd498", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + } + ], + "ready": false + }, + "labels": [ + { + "title": "external_block", + "number": 2864, + "repo": "iFixit/server-templates", + "user": "ardelato", + "created_at": "2020-12-03T00:24:43.000Z" + } + ] + }, + { + "repo": "iFixit/server-templates", + "number": 2882, + "state": "open", + "title": "Selenium: Only update the server, not the chromedriver", + "body": "pull request dummy body", + "created_at": "2020-12-17T02:10:41.000Z", + "updated_at": "2020-12-18T23:54:26.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": "Dozuki - 12/15/2020", + "due_on": "2020-12-15T08:00:00.000Z" + }, + "head": { + "ref": "selenium-grid--update-hub-but-not-nodes", + "sha": "88398908d77aaee628865c7d5e252e6b8ae60920", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "BaseInfinity" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2882, + "user": { + "id": 29667789, + "login": "mlahargou" + }, + "type": "QA", + "created_at": "2020-12-17T17:58:04.000Z", + "active": 0, + "comment_id": 747600998 + } + } + ], + "allCR": [ + { + "data": { + "repo": "iFixit/server-templates", + "number": 2882, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "CR", + "created_at": "2020-12-18T23:20:53.000Z", + "active": 0, + "comment_id": 748369425 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2882, + "user": { + "id": 6876047, + "login": "djmetzle" + }, + "type": "CR", + "created_at": "2020-12-17T02:20:49.000Z", + "active": 0, + "comment_id": 747161179 + } + }, + { + "data": { + "repo": "iFixit/server-templates", + "number": 2882, + "user": { + "id": 3336250, + "login": "caphene" + }, + "type": "CR", + "created_at": "2020-12-17T02:17:40.000Z", + "active": 0, + "comment_id": 747160194 + } + } + ], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "88398908d77aaee628865c7d5e252e6b8ae60920", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/server-templates", + "number": 2886, + "state": "closed", + "title": "Akeneo: This pull is closed and should be hidden", + "body": "pull request dummy body", + "created_at": "2020-12-23T01:36:52.000Z", + "updated_at": "2020-12-23T01:37:27.000Z", + "closed_at": null, + "merged_at": null, + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "akeneo-rds--add-chartio", + "sha": "a728494a733bbed5748efbe94cbab8dc8bb0657c", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "danielbeardsley" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "a728494a733bbed5748efbe94cbab8dc8bb0657c", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/server-templates", + "number": 2986, + "state": "closed", + "title": "Akeneo: Finish the migration", + "body": "pull request dummy body", + "created_at": "2020-12-23T01:36:52.000Z", + "updated_at": "2020-12-23T01:37:27.000Z", + "merged_at": null, + "closed_at": "2020-12-25T01:39:27.000Z", + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "akeneo-finish-the-thing", + "sha": "a728494a733bbed5748efbe94cbab8dc8bb0657c", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "danielbeardsley" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "a728494a733bbed5748efbe94cbab8dc8bb0657c", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + } + ], + "ready": false + }, + "labels": [] + }, + { + "repo": "iFixit/ifixit", + "number": 38986, + "state": "closed", + "title": "Routing: tripple the bypass udocles", + "body": "pull request dummy body", + "created_at": "2020-12-23T01:36:52.000Z", + "updated_at": "2020-12-23T01:37:27.000Z", + "closed_at": "2020-12-25T01:37:27.000Z", + "merged_at": "2020-12-25T01:37:27.000Z", + "difficulty": null, + "milestone": { + "title": null, + "due_on": null + }, + "head": { + "ref": "akeneo-finish-the-thing", + "sha": "a728494a733bbed5748efbe94cbab8dc8bb0657c", + "repo": { + "owner": { + "login": "iFixit" + } + } + }, + "base": { + "ref": "master" + }, + "user": { + "login": "notme" + }, + "cr_req": 2, + "qa_req": 1, + "status": { + "qa_req": 1, + "cr_req": 2, + "QA": [], + "CR": [], + "allQA": [], + "allCR": [], + "dev_block": [], + "deploy_block": [], + "commit_statuses": [ + { + "data": { + "sha": "a728494a733bbed5748efbe94cbab8dc8bb0657c", + "target_url": "https://wwww.example.com", + "description": "Build success", + "state": "success", + "context": "phpunit" + } + } + ], + "ready": false + }, + "labels": [] + } +] \ No newline at end of file diff --git a/frontend-v2/src/backend/dummy.ts b/frontend-v2/src/backend/dummy.ts new file mode 100644 index 00000000..61d62b62 --- /dev/null +++ b/frontend-v2/src/backend/dummy.ts @@ -0,0 +1,267 @@ +import type { InitializePayload, PullData } from '../../../shared/types'; + +/** + * Dummy mode: run the whole UI without a backend. + * npm run dev:dummy + * The fixture is a frozen snapshot of the wire shape (it moved here when the + * legacy v1 frontend was retired), lazy-imported so it never lands in the + * production bundle. + */ +export function isDummy(): boolean { + return import.meta.env.VITE_DUMMY === '1'; +} + +export function dummyUser(): string { + return import.meta.env.VITE_DUMMY_USER || 'danielbeardsley'; +} + +export async function loadDummy(): Promise { + const raw = (await import('./dummy-pulls.json')).default as unknown as PullData[]; + // The fixture is a decade of frozen pulls; re-date them so age-derived + // signals (heat, starvation, freshness) exercise realistically. + // the frozen fixture predates diff stats on the wire; the real server now + // guarantees them on every open pull, so the demo board synthesizes a + // deterministic spread that exercises every weight class (and the + // >15-files bump) instead of reading all-XS + const SIZE_SPREAD = [12, 38, 90, 140, 320, 560, 900, 1400, 2600]; + const withSizes = (p: PullData, i: number): PullData => + p.additions != null + ? p + : { + ...p, + additions: Math.round(SIZE_SPREAD[i % SIZE_SPREAD.length] * 0.7), + deletions: Math.round(SIZE_SPREAD[i % SIZE_SPREAD.length] * 0.3), + changed_files: 1 + (i % 22), + }; + // the fixture's bodies are one flat placeholder line; rotate realistic + // markdown so the title's description preview demos what it renders + // (headings, checklists, code, links) + const BODIES = [ + 'closes #4821\n\n## Summary\nMoves the cart badge count out of the page render and onto the shared header stream, so cached pages stop showing a stale count.\n\n## QA\n- [x] badge updates after adding to cart\n- [ ] logged-out view shows no badge\n- [ ] works on the checkout pages', + 'The old query scanned `pull_signatures` per row; this batches it:\n\n```sql\nSELECT number, MAX(date) FROM pull_signatures GROUP BY number\n```\n\nCuts the dashboard load from ~4s to ~300ms on prod data. See [the profile](https://example.com/profile) for before/after.', + '## Why\nSupport keeps getting "my order vanished" tickets — the order list dropped rows with a null `shipped_at`.\n\n> Root cause: the join predicate treated NULL as false.\n\nOne-line fix plus a regression test.', + ]; + const withBody = (p: PullData, i: number): PullData => + p.body === 'pull request dummy body' ? { ...p, body: BODIES[i % BODIES.length] } : p; + const pulls = withSyntheticStacks(raw).map((p, i) => withBody(withSizes(redate(p, i), i), i)); + return { + repos: [{ name: 'iFixit/ifixit' }], + // config the live server delivers here too — a bot login and the weight + // labels, so the dummy board exercises bot folding and label weights + bots: ['renovate-bot'], + weightLabels: { + 'size: XS': 'XS', + 'size: S': 'S', + 'size: M': 'M', + 'size: L': 'L', + 'size: XL': 'XL', + }, + // The fixture carries almost no closed/merged pulls and no diff sizes, so + // the Stats lens (merge-time-by-size, leaderboards over shipped work) has + // nothing to show. Synthesize a fortnight of merged PRs across the size + // range so those panels demo. Dummy-only — real data carries this for real. + pulls: [...pulls, ...synthMerged(pulls)], + }; +} + +// The fixture's signature timestamps are as frozen as its pulls; spread them +// across the given window so stamp-timeline stats (review pulse, first-CR +// latency) exercise realistically instead of reading a decade-old wall. +function redateSigs( + status: PullData['status'], + startMs: number, + endMs: number +): PullData['status'] { + const span = Math.max(endMs - startMs, 3_600_000); + const shift = (sigs: S[], salt: number): S[] => + sigs.map((s, j) => ({ + ...s, + data: { + ...s.data, + created_at: new Date( + startMs + ((j + 1) * span) / (sigs.length + 1) + salt + ).toISOString(), + }, + })); + return { ...status, allCR: shift(status.allCR, 0), allQA: shift(status.allQA, 600_000) }; +} + +// The imperative-nudge system (model/actions.ts rowNote) reads `participants` +// and `status.unstamped_reviewers` to say "answer their review" / "in +// discussion with" — both postdate this fixture, so a few fixed pulls carry +// them here (deterministic indexes, no randomness) to keep those nudges +// QA-able in dummy mode. review_id/body let the state popover's feedback +// section trigger too. +const CHANGES_REQUESTED_INDEX = 2; +const COMMENTED_INDEX = 8; +const EXTRA_PARTICIPANT_INDEXES = new Set([2, 5, 6, 8]); + +function unstampedReviewerFor( + i: number, + atEpochSecs: number +): PullData['status']['unstamped_reviewers'] { + if (i === CHANGES_REQUESTED_INDEX) { + return [ + { + login: 'grumpy-reviewer', + state: 'CHANGES_REQUESTED', + date: atEpochSecs, + review_id: 800000001, + body: 'This needs another pass on the error handling before I can sign off — see the inline comments.', + }, + ]; + } + if (i === COMMENTED_INDEX) { + return [ + { + login: 'curious-commenter', + state: 'COMMENTED', + date: atEpochSecs, + review_id: 800000002, + body: 'Nice cleanup overall — one question about the retry logic, otherwise this looks good to me.', + }, + ]; + } + return undefined; +} + +/** + * A dev-blocked dummy pull's block signature needs a comment_id so the state + * popover can build a GitHub permalink for it (signatureUrl). The fixture's + * dev_block signatures already carry one, but this keeps the demo honest if + * that ever stops being true. + */ +function withDevBlockCommentId(status: PullData['status']): PullData['status'] { + if (!status.dev_block.some(s => !s.data.comment_id)) return status; + return { + ...status, + dev_block: status.dev_block.map((s, j) => ({ + ...s, + data: { ...s.data, comment_id: s.data.comment_id || 900_000_000 + j }, + })), + }; +} + +// Participants beyond the CR/QA stampers already on the pull, so +// engagedNoStamp (a review with no stamp, or a comment-only bystander) has +// something to derive. +function participantsFor(pull: PullData, i: number): string[] { + if (!EXTRA_PARTICIPANT_INDEXES.has(i)) return pull.participants ?? []; + const stampers = [...pull.status.allCR, ...pull.status.allQA].map(s => s.data.user.login); + const extra = + i === CHANGES_REQUESTED_INDEX + ? ['grumpy-reviewer'] + : i === COMMENTED_INDEX + ? ['curious-commenter'] + : ['silent-lurker']; + return [...new Set([...(pull.participants ?? []), ...stampers, ...extra])]; +} + +// Fixed raw-fixture indices rewritten into two stacked-PR demos for +// model/stack.ts's groupIntoTree (deterministic, same spirit as the rest of +// this file's index-keyed synthesis): a 3-deep chain (#35168 -> #35177 -> +// #35194) and one parent with two children (#35207 -> #35236, #35249). Left +// untouched: indices 5-7, where the fixture already carries the ambiguous- +// parent case unassisted — #35103 and #351011 share a head ref, and #35059 +// (index 5) already bases off it, so groupIntoTree's flat fallback is +// QA-able without any synthesis here. +const CHAIN_INDEXES = [10, 11, 12] as const; // parent, child, grandchild +const FORK_INDEXES = [13, 14, 15] as const; // parent, child, child + +// `requested_reviewers`/`assignees` postdate this fixture, so some pulls carry a +// review request aimed at the dummy viewer (so the "Requested of you" lane and +// the row's "review requested" chip demo) and a couple of assignees. Keyed off +// the pull's own author, not a raw index, since a request only surfaces on +// someone else's PR — every third non-viewer pull gets one, deterministically. +const wantsReviewRequest = (pull: PullData, i: number): boolean => + pull.user.login !== dummyUser() && i % 3 === 1; +const ASSIGNEE_INDEXES = new Set([6, 12]); + +function withSyntheticStacks(pulls: PullData[]): PullData[] { + const out = [...pulls]; + const rebase = (i: number, baseRef: string) => { + out[i] = { ...out[i], base: { ...out[i].base, ref: baseRef } }; + }; + const [chainParent, chainChild, chainGrandchild] = CHAIN_INDEXES; + rebase(chainChild, out[chainParent].head.ref); + rebase(chainGrandchild, out[chainChild].head.ref); + const [forkParent, forkChild1, forkChild2] = FORK_INDEXES; + rebase(forkChild1, out[forkParent].head.ref); + rebase(forkChild2, out[forkParent].head.ref); + // The chain belongs to the dummy viewer: a dependent pull's status differs + // from its parent's, so on Review the members scatter across lanes and + // only ever demo the flat-with-stub case. My work is the one surface that + // holds a whole chain in one list (your own PRs, and WordGroupRows pulls + // children into their root's bucket), so a viewer-authored chain is the + // standing FULL demo of nesting: indent + elbow, three deep. The fork + // stays authored by others, which keeps the split-stack stub demo alive + // on Review at the same time. + for (const i of CHAIN_INDEXES) { + out[i] = { ...out[i], user: { ...out[i].user, login: dummyUser() } }; + } + // ...and every chain member gets a merge conflict and a clean signature + // slate. The children are already 'unmergeable' by virtue of being + // dependent, but per-member noise (the grandchild's fixture carries a + // stale CR, the root would otherwise be a plain wait) scatters the three + // across the move/wait lanes, and a chain only nests when its members + // share one list. Uniform state = one bucket = the standing 3-deep demo + // of indent + elbow on My work and Review both. + for (const i of CHAIN_INDEXES) { + out[i] = { + ...out[i], + mergeable: false, + status: { ...out[i].status, allCR: [], allQA: [] }, + }; + } + return out; +} + +function redate(pull: PullData, i: number): PullData { + const now = Date.now(); + const ageDays = (i * 7919) % 45; // deterministic spread, 0-45 days + const created = new Date(now - ageDays * 86400_000); + const updated = new Date(now - ((i * 104729) % (72 * 3600_000))); + // synthesize a diff size when the fixture omits one, so the weight meter + // and size-derived stats aren't all flat XS in dummy mode + const additions = pull.additions ?? 17 + ((i * 4099) % 1600); + const deletions = pull.deletions ?? (i * 1237) % 400; + const status = withDevBlockCommentId(redateSigs(pull.status, created.getTime(), now)); + const unstamped_reviewers = unstampedReviewerFor(i, Math.floor(created.getTime() / 1000) + 3600); + return { + ...pull, + created_at: created.toISOString(), + updated_at: updated.toISOString(), + status: unstamped_reviewers ? { ...status, unstamped_reviewers } : status, + participants: participantsFor(pull, i), + additions, + deletions, + requested_reviewers: wantsReviewRequest(pull, i) + ? [dummyUser()] + : (pull.requested_reviewers ?? []), + assignees: ASSIGNEE_INDEXES.has(i) ? [dummyUser()] : (pull.assignees ?? []), + }; +} + +// Clone a slice of the board into merged PRs, closed within the last 14 days, +// with merge time that rises with size (plus jitter) so the trend is visible. +function synthMerged(pulls: PullData[]): PullData[] { + const now = Date.now(); + return pulls.slice(0, 16).map((p, i) => { + const size = (p.additions ?? 0) + (p.deletions ?? 0); + const mergeHours = 1.5 + size / 45 + ((i * 53) % 34); + const closedDaysAgo = (i * 3) % 14; + const merged = now - closedDaysAgo * 86400_000; + const created = merged - mergeHours * 3_600_000; + return { + ...p, + number: 90000 + i, + state: 'closed', + draft: false, + created_at: new Date(created).toISOString(), + updated_at: new Date(merged).toISOString(), + closed_at: new Date(merged).toISOString(), + merged_at: new Date(merged).toISOString(), + status: redateSigs(p.status, created, merged), + }; + }); +} diff --git a/frontend-v2/src/backend/socket.ts b/frontend-v2/src/backend/socket.ts new file mode 100644 index 00000000..6c52b15b --- /dev/null +++ b/frontend-v2/src/backend/socket.ts @@ -0,0 +1,269 @@ +import { io, type Socket } from 'socket.io-client'; +import type { InitializePayload, PullData, TokenResponse } from '../../../shared/types'; +import { isDummy, loadDummy, dummyUser } from './dummy'; +import { readSessionStorage, writeSessionStorage } from '../storage'; + +export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error'; + +/** Thrown by the token fetch when the app SESSION is dead (a redirect into the + * OAuth flow, or a 401) -- distinct from a transient network/5xx failure, so + * only this one flips the "sign in again" banner. */ +class AuthExpiredError extends Error { + constructor(status: number) { + super(`session expired (${status})`); + this.name = 'AuthExpiredError'; + } +} + +export interface Backend { + /** Resolves once we know who the viewer is (the /token user). */ + whoami: () => Promise; + /** Streams the initial snapshot, then one pull per change. */ + onPulls: (handler: (payload: InitializePayload | PullData) => void) => void; + onConnection: (handler: (state: ConnectionState) => void) => () => void; + /** Streams whether the app session is dead (a /token redirect or 401 on + * (re)auth), so the UI can say "sign in again" instead of "retrying". */ + onAuthExpired: (handler: (expired: boolean) => void) => () => void; + refreshPull: (repo: string, number: number) => void; + /** Ask to review a pull. The server derives the login from the socket's + * own auth — no login argument here. A claim is a GitHub review request + * the reviewer made on themselves (see types.ts's review_requests), so it + * has no client-side TTL: it clears when the review is submitted, + * released, or removed on GitHub, same as any other review request. */ + claimReview: (repo: string, number: number) => void; + releaseReview: (repo: string, number: number) => void; +} + +function liveBackend(): Backend { + let socket: Socket | null = null; + let token: Promise | null = null; + + const getToken = () => { + token = + token ?? + fetch('/token').then(async r => { + // an expired session redirects toward the OAuth flow (or 401s) + // instead of returning JSON. Separate that dead-session case from + // a transient network/5xx failure, so only the former says "sign + // in again" while a blip keeps retrying quietly. + if (r.redirected || r.status === 401) throw new AuthExpiredError(r.status); + if (!r.ok) throw new Error(`token fetch failed: ${r.status}`); + const t = (await r.json()) as TokenResponse; + if (!t.socketToken) throw new Error('token response missing socketToken'); + return t; + }); + // a failed fetch must not poison the cache: the next caller retries + // instead of replaying tonight's rejection forever + token.catch(() => { + token = null; + }); + return token; + }; + + let onState: ((state: ConnectionState) => void) | null = null; + let onAuth: ((expired: boolean) => void) | null = null; + + // Overnight, the app session cookie can expire while the tab sleeps; the + // socket then reconnects fine but /token fails and the board would sit on + // "retrying" forever. A visible, online tab whose token keeps failing gets + // ONE reload per 5 minutes: the page-level auth gate re-runs the OAuth + // handshake (silent with a live GitHub session) and comes back signed in. + const RELOAD_AT_KEY = 'pd2.authReloadAt'; + const maybeReloadForAuth = () => { + if (document.visibilityState !== 'visible' || !navigator.onLine) return; + const last = Number(readSessionStorage(RELOAD_AT_KEY) || 0); + if (Date.now() - last < 5 * 60_000) return; + writeSessionStorage(RELOAD_AT_KEY, String(Date.now())); + location.reload(); + }; + + // The auth handshake retries on its own: a socket that reconnected after a + // sleep is useless until 'authenticate' lands, so a token failure backs + // off (2s → 30s cap) instead of giving up after one shot. A new connect or + // a disconnect always resets the loop. + let authRetry: ReturnType | null = null; + const clearAuthRetry = () => { + if (authRetry != null) clearTimeout(authRetry); + authRetry = null; + }; + const authenticate = (attempt = 0) => { + // Socket tokens are single-use and expire quickly: re-fetch on every + // (re)connect rather than reusing the first one. + token = null; + getToken().then( + t => { + // a valid /token proves the app session is alive again + onAuth?.(false); + socket!.emit('authenticate', t.socketToken); + }, + (err: unknown) => { + // a dead session (redirect/401) is "sign in again", not "retrying"; + // a transient failure leaves authFailed alone and just backs off + if (err instanceof AuthExpiredError) onAuth?.(true); + onState?.('error'); + if (attempt >= 1) maybeReloadForAuth(); + clearAuthRetry(); + authRetry = setTimeout( + () => authenticate(attempt + 1), + Math.min(30_000, 2_000 * 2 ** attempt) + ); + } + ); + }; + + const getSocket = () => { + if (socket) return socket; + socket = io(); + socket.on('connect', () => { + clearAuthRetry(); + authenticate(); + }); + socket.on('disconnect', () => clearAuthRetry()); + socket.on('connect_error', () => onState?.('error')); + // Chrome freezes background tabs and laptops sleep; on wake, socket.io + // would sit out the rest of its backoff before trying again. Any wake + // signal — the tab becoming visible, the window refocusing, the network + // returning — skips the wait and reconnects now. + const wake = () => { + if (socket && !socket.connected) socket.connect(); + }; + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') wake(); + }); + window.addEventListener('focus', wake); + window.addEventListener('online', wake); + return socket; + }; + + return { + whoami: () => getToken().then(t => t.user), + onPulls(handler) { + const s = getSocket(); + s.on('initialize', (data: InitializePayload) => handler(data)); + s.on('pullChange', (pull: PullData) => handler(pull)); + }, + onConnection(handler) { + onState = handler; + const s = getSocket(); + const connected = () => handler('connected'); + const disconnected = () => handler('disconnected'); + const connecting = () => handler('connecting'); + s.on('connect', connected); + s.on('disconnect', disconnected); + s.io.on('reconnect_attempt', connecting); + return () => { + onState = null; + s.off('connect', connected); + s.off('disconnect', disconnected); + s.io.off('reconnect_attempt', connecting); + }; + }, + onAuthExpired(handler) { + onAuth = handler; + return () => { + onAuth = null; + }; + }, + refreshPull(repo, number) { + getSocket().emit('refresh', repo, number); + }, + claimReview(repo, number) { + getSocket().emit('claimReview', repo, number); + }, + releaseReview(repo, number) { + getSocket().emit('releaseReview', repo, number); + }, + }; +} + +function dummyBackend(): Backend { + let emit: ((payload: InitializePayload | PullData) => void) | null = null; + let loaded: InitializePayload | null = null; + let staggered = 0; + + /** Mutate one dummy pull in place and re-emit it through the normal + * pullChange path — the dummy board's stand-in for a server round trip + * (a claim/release now travels as a pull update, not a separate map). */ + function updatePull(repo: string, number: number, fn: (pull: PullData) => PullData) { + if (!loaded) return; + const idx = loaded.pulls.findIndex(p => p.repo === repo && p.number === number); + if (idx === -1) return; + const next = fn(loaded.pulls[idx]); + loaded.pulls[idx] = next; + emit?.(next); + } + + return { + whoami: () => Promise.resolve(dummyUser()), + onPulls(handler) { + emit = handler; + void loadDummy().then(payload => { + loaded = payload; + handler(payload); + // Seed one pre-existing claim by a fixture teammate on a + // deterministic reviewable pull, so "X is reading it" is + // QA-able without a second client — a beat after load so it + // reads as someone already at their desk, not a race with the + // initial render. + setTimeout(() => { + const target = payload.pulls.find( + p => + p.state === 'open' && + !p.draft && + p.user.login !== dummyUser() && + p.status.allCR.filter(s => s.data.active).length < p.status.cr_req + ); + if (!target) return; + updatePull(target.repo, target.number, pull => ({ + ...pull, + review_requests: [ + ...(pull.review_requests ?? []), + { login: 'dummy-teammate', at: Math.floor(Date.now() / 1000), self: true }, + ], + })); + }, 500); + }); + }, + onConnection(handler) { + handler('connected'); + return () => undefined; + }, + onAuthExpired() { + return () => undefined; + }, + refreshPull(repo, number) { + // The dummy data is static, but the refresh-progress UI still needs + // arrivals to count — re-emit the same pull on a stagger so the + // "X of N" counter animates to completion like a real refresh would. + const pull = loaded?.pulls.find(p => p.repo === repo && p.number === number); + if (!pull || !emit) return; + staggered += 1; + setTimeout( + () => { + staggered -= 1; + emit?.(pull); + }, + 300 + staggered * 15 + ); + }, + claimReview(repo, number) { + updatePull(repo, number, pull => ({ + ...pull, + requested_reviewers: [...new Set([...(pull.requested_reviewers ?? []), dummyUser()])], + review_requests: [ + ...(pull.review_requests ?? []).filter(r => r.login !== dummyUser()), + { login: dummyUser(), at: Math.floor(Date.now() / 1000), self: true }, + ], + })); + }, + releaseReview(repo, number) { + updatePull(repo, number, pull => ({ + ...pull, + requested_reviewers: (pull.requested_reviewers ?? []).filter(l => l !== dummyUser()), + review_requests: (pull.review_requests ?? []).filter(r => r.login !== dummyUser()), + })); + }, + }; +} + +export const backend: Backend = isDummy() ? dummyBackend() : liveBackend(); diff --git a/frontend-v2/src/components/Card.tsx b/frontend-v2/src/components/Card.tsx new file mode 100644 index 00000000..1b9037bd --- /dev/null +++ b/frontend-v2/src/components/Card.tsx @@ -0,0 +1,218 @@ +import type { ReactNode } from 'react'; +import { PullTitleLink } from './bits'; +import { Avatar } from './identity'; + +/** + * The one row every lens renders. Two densities of the same content: + * + * - comfortable: avatar + full-width wrapping title on top, one meta line + * below. Giving the title its own line lets a long title wrap cleanly + * instead of fighting a dozen metadata chips, and lets the card reflow to a + * phone. + * - compact: everything on one flowing line — avatar, title, meta chips, the + * right-anchored rail — with a smaller avatar and tighter padding. When the + * column is narrower than the content, the line wraps; nothing ever + * ellipsizes. Density comes from the tighter geometry, never from hiding + * text. + * + * The meta content is the caller's; only the shell (and its geometry) is + * shared, so the rail lands at the same x down the board in every lens. The + * title link stretches over the whole card (`stretch`), so a click anywhere + * opens the PR; genuinely interactive children opt back out with `.pd-raise`. + */ +/** Per-depth left inset for a stacked (nested) row: enough to read as a + * child without starving narrow columns (Classic's are the tight case). */ +const STACK_INDENT_PX = 16; + +/** The comfortable/compact left-pad a stack connector starts from — shared + * by StackConnector and StackStub so the two elbow shapes always align on + * the same origin regardless of density. */ +const stackBasePad = (compact: boolean) => (compact ? 12 : 14); + +/** The elbow before a nested row's avatar — one CSS hairline that says + * "child of the row above". Absolutely positioned inside the indent gutter + * so it costs the row zero flex width (a glyph in the flow both rendered as + * a literal "L" and squeezed the rail into overflow in narrow columns). */ +function StackConnector({ compact, depth }: { compact: boolean; depth: number }) { + const basePad = stackBasePad(compact); + return ( + + ); +} + +/** + * The connector's other standing: a stacked row whose parent ISN'T the row + * above (it lives in another lane, a fold, or another lens' scope) can't + * nest, so it wears the same elbow truncated to a stub — a few quiet pixels + * of the same line, rising to the row's top edge where the parent would + * have been. One vocabulary, two states: the full elbow means "child of + * the row above", the stub means "child of something that isn't here". The + * row's "stacked on #N" flag stays the door that names and links the + * parent; this is pure geometry, so the split-stack case reads at a glance + * instead of only in words. + */ +function StackStub({ compact }: { compact: boolean }) { + const basePad = stackBasePad(compact); + return ( + + ); +} + +export function CardShell({ + login, + onPerson, + repo, + number, + title, + body, + id, + className = '', + meta, + rail, + stretch = true, + compact = false, + own = false, + bot = false, + avatarBadge, + edge, + depth = 0, + stackStub = false, +}: { + login: string; + onPerson?: (login: string) => void; + repo: string; + number: number; + title: string; + /** PR description for the title's hover preview */ + body?: string; + /** stable DOM id (format.ts's rowDomId) — lets toasts scroll to and + * flash the row it just claimed. */ + id?: string; + className?: string; + /** the meta line: badge, repo#number, context, flags */ + meta: ReactNode; + /** the fixed metric rail (CI, sign-off marks, weight). A separate slot on + * purpose: comfortable renders it as its own right-hand column, vertically + * centered in the row; compact keeps it in the flowing line. In narrow + * columns the container query wraps it to a full-width line below. */ + rail?: ReactNode; + stretch?: boolean; + compact?: boolean; + /** the viewer authored this pull: the avatar wears the you-mark (the + * corner star seated in a bite on its rim — see identity.tsx's identity + * system note) */ + own?: boolean; + /** a bot/app author: the avatar renders as a rounded-square tile instead + * of a circle — shape is the whole mark */ + bot?: boolean; + /** a tiny marker absolutely-positioned over the avatar (the row's teammate + * author ★) — a slot rather than an Avatar prop, so this stays a one- + * caller concern instead of touching every Avatar call site. */ + avatarBadge?: ReactNode; + /** a row-edge overlay (the age baseline): absolutely positioned against + * the pd-row (already relative), rendered last so it paints over the + * divider without entering the flex flow. Hoverable/focusable now (its + * own popover door), not pure decoration. */ + edge?: ReactNode; + /** stack-nesting depth (0 = top-level): indents the row and shows a + * connector elbow before the avatar — model/stack.ts's groupIntoTree + * supplies it. Capped at 2 by the model; the geometry doesn't need its + * own cap on top of that. */ + depth?: number; + /** a stacked pull rendering flat (its parent isn't the row above): wears + * the connector's stub form — see StackStub. Ignored when depth > 0. */ + stackStub?: boolean; +}) { + const titleLink = ( + + ); + const connector = + depth > 0 ? ( + + ) : stackStub ? ( + + ) : null; + + if (compact) { + return ( +
+ {connector} + {/* raise only when the avatar is a real button — a raised inert + span punches a dead zone into the whole-row click target */} + + + {avatarBadge} + + {/* nothing here truncates: the row flows as one tight line and + wraps when the column is narrower than the content — density + comes from geometry, never from hiding text. The rail rides + the same flow (its ml-auto keeps it right-aligned), so in a + narrow column it drops below the text instead of starving it */} + + {titleLink} + {meta} + {rail} + + {edge} +
+ ); + } + + return ( +
+ {connector} + + + {avatarBadge} + + + {titleLink} + + {meta} + + + {/* the rail rides as its own right-hand column, vertically centered + in the row (styles.css .pd-row > .pd-rail) — the two-line card's + height becomes deliberate space around the marks instead of dead + air above trailing chips */} + {rail} + {edge} +
+ ); +} diff --git a/frontend-v2/src/components/ClosedRow.tsx b/frontend-v2/src/components/ClosedRow.tsx new file mode 100644 index 00000000..a605e12f --- /dev/null +++ b/frontend-v2/src/components/ClosedRow.tsx @@ -0,0 +1,61 @@ +import { memo } from 'react'; +import type { PullData } from '../../../shared/types'; +import { ago, closedEpoch } from '../../../shared/format'; +import { PullTitleLink, RepoRef } from './bits'; +import { Avatar } from './identity'; +import { ClosedBadgeTrigger } from './StatePopover'; + +/** + * Full-width receipt row. The badge + avatar are flex-none; everything else + * lives in ONE flexible min-w-0 column — title on top, a muted repo · age line + * under it — so nothing competes with the title for width. The old layout kept + * the repo ref + age in a flex-none whitespace-nowrap cluster on the right, + * which on a phone starved the title to ~0px and stacked it one char per line. + * Nothing here truncates: the title wraps instead. + * Memoized like Row: closed rows live in folds that re-render with every + * publish, but a closed pull's data never changes again. + */ +export const ClosedRow = memo(function ClosedRow({ + pull, + lastSeen, +}: { + pull: PullData; + lastSeen?: number; +}) { + const merged = !!pull.merged_at; + const closedAt = closedEpoch(pull); + const fresh = lastSeen != null && closedAt > lastSeen; + return ( +
+ {fresh && ( + + )} + + + + + + + + + + + + + {ago(closedAt)} ago + + +
+ ); +}); diff --git a/frontend-v2/src/components/Column.tsx b/frontend-v2/src/components/Column.tsx new file mode 100644 index 00000000..d2a33f6f --- /dev/null +++ b/frontend-v2/src/components/Column.tsx @@ -0,0 +1,64 @@ +import { useState, type ReactNode } from 'react'; +import { ChevronRight } from 'lucide-react'; +import { Icon } from './Icon'; + +/** + * The board column shell shared by Board, Classic, and Recently Closed: + * a header bar (attached when open, a closed pill when collapsed) with a + * count on the right, over a rounded body. `header` carries whatever the + * lens puts before the count — a plain title, or a status dot plus hint. + */ +export function BoardColumn({ + header, + count, + defaultOpen = true, + children, +}: { + header: ReactNode; + count: number; + defaultOpen?: boolean; + children: ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen); + return ( +
+ {/* sticks below the app header while its column scrolls; the canvas + backing fills the gap outside the button's rounded corners */} +

+ +

+ {open && ( + // min-h keeps the rounded-b corners from collapsing to square when + // the body is empty (Classic keeps empty columns visible): a ~1px + // box has no room to draw a 16px radius. Any populated column far + // exceeds this, so it only shows on the empty case. +
+ {children} +
+ )} +
+ ); +} diff --git a/frontend-v2/src/components/Icon.tsx b/frontend-v2/src/components/Icon.tsx new file mode 100644 index 00000000..95d587c1 --- /dev/null +++ b/frontend-v2/src/components/Icon.tsx @@ -0,0 +1,23 @@ +import type { ForwardRefExoticComponent, SVGProps } from 'react'; + +export type LucideComponent = ForwardRefExoticComponent< + SVGProps & { size?: number | string } +>; + +/** + * The one lucide wrapper every icon in the app renders through (per-icon + * imports at the call site keep tree-shaking — never `import * as icons`). + * House defaults: 14px for inline/action icons, pass size={16} for header + * chrome; lucide's own stroke weight is never overridden here — a second + * weight would be a second visual family. aria-hidden by default, since an + * icon almost always rides beside text or inside an already-labeled button; + * pass aria-hidden={false} (with the caller's own accessible name) on the + * rare icon that IS the label. + */ +export function Icon({ + icon: Glyph, + size = 14, + ...rest +}: { icon: LucideComponent } & SVGProps & { size?: number | string }) { + return ; +} diff --git a/frontend-v2/src/components/Lane.tsx b/frontend-v2/src/components/Lane.tsx new file mode 100644 index 00000000..d47ce426 --- /dev/null +++ b/frontend-v2/src/components/Lane.tsx @@ -0,0 +1,428 @@ +import { useRef, useState, type ReactNode, type SyntheticEvent } from 'react'; +import { ChevronRight } from 'lucide-react'; +import { pullKey } from '../../../shared/format'; +import type { DerivedPull } from '../../../shared/model/status'; +import { groupIntoTree } from '../model/stack'; +import { createPersistentStore } from '../storage'; +import { Icon } from './Icon'; +import { Popover } from './Popover'; +import { Row, type RowOptions } from './Row'; + +/** + * Explicitly-chosen fold open/closed state, one entry per fold `id`. A fold + * with no entry here falls back to its `defaultOpen` — this only remembers a + * choice the user actually made, so a later `defaultOpen` change (e.g. the + * lonely-board auto-open) still applies to folds nobody has touched yet. + */ +const foldOpenStore = createPersistentStore>('pd2.folds', {}); + +/** The DOM id a fold with this store `id` renders under — colons aren't + * valid in a CSS selector, so callers that need to query the element (a + * banner jumping to "recently shipped") go through this instead of + * reconstructing the prefix by hand. */ +export function foldDomId(id: string): string { + return `fold-${id.replace(/:/g, '-')}`; +} + +/** Open a fold from outside Lane.tsx — e.g. the "N merged since your last + * look" banner jumping straight to the shipped fold — by writing the same + * store its own toggle reads. A no-op if it's already open. */ +export function openFold(id: string): void { + const cur = foldOpenStore.get(); + if (cur[id] !== true) foldOpenStore.set({ ...cur, [id]: true }); +} + +/** + * A lane sub-line that is itself the door to the full story: the one-sentence + * ordering statement stays in the header (dotted underline, zero chrome at + * rest), and hovering it opens what lands in the lane and how it's ranked. + * This is the house pattern for every curated lane — if a user has to ask + * why a card is here, the lane's own sub-line should have answered it. + */ +export function SubDoor({ + label, + text, + children, +}: { + /** the popover's accessible name, e.g. "How the queue is ranked" */ + label: string; + /** the visible sub-line sentence */ + text: string; + children: ReactNode; +}) { + return ( + ( + + )} + > +
{children}
+
+ ); +} + +export function Rows({ children }: { children: ReactNode }) { + return ( +
{children}
+ ); +} + +/** The shared lane/group header: title, optional subtitle, optional + * right-aligned count and/or an extra control. Exported so one-off sections + * use the one header system instead of hand-rolling a lookalike. */ +export function GroupHeader({ + title, + sub, + count, + compact, + headerExtra, +}: { + title: string; + /** plain string for most lanes; a ReactNode when the sub-line itself is + * the door to more detail (Review's queue wraps its ranking one-liner in + * a popover — existing text becomes interactive, zero new chrome) */ + sub?: ReactNode; + count?: number; + compact?: boolean; + headerExtra?: ReactNode; +}) { + return ( + // sticky just under the app header (top from the measured --header-h), + // opaque over the canvas so long lanes keep their context while rows + // scroll beneath; the old margin-below became padding so the spacing + // itself is part of the opaque surface +
+

+ {title} +

+ {sub && {sub}} + {/* an empty section earns a title, never a "0" — the count only + appears once there's something to count; headerExtra alone can + still earn the right-aligned slot on an otherwise count-less lane */} + {(!!count || headerExtra) && ( + <> + + {!!count && {count}} + {headerExtra} + + )} +
+ ); +} + +/** + * Rows a lane shows before folding into "+N more": the user's lane-length + * setting (0 = no cap) over the lane's default, with compact density packing + * ~50% more rows per fold. The one cap convention — any lane that can't use + * (custom children) must still cap through this. + */ +export function laneShown(defaultCap: number, opts: RowOptions): number { + const base = opts.laneCap ?? defaultCap; + return base === 0 ? Number.POSITIVE_INFINITY : opts.compact ? Math.ceil(base * 1.5) : base; +} + +export function Lane({ + title, + sub, + pulls, + count, + cap = 10, + opts, + children, + headerExtra, +}: { + title: string; + sub?: ReactNode; + pulls: DerivedPull[]; + /** header count when rows come in via children instead of pulls */ + count?: number; + cap?: number; + opts: RowOptions; + /** extra rows rendered inside the container, before the more-line */ + children?: ReactNode; + /** an extra control right-aligned in the header */ + headerExtra?: ReactNode; +}) { + if (!pulls.length && !children) return null; + const shown = laneShown(cap, opts); + const tree = groupIntoTree(pulls); + return ( +
+ + + {children} + + {tree.map(({ pull: p, depth }) => ( + + ))} + + +
+ ); +} + +/** The standard fold body: capped, expandable rows for a list of pulls. */ +export function FoldRows({ + list, + opts, + id, + cap, + label, +}: { + list: DerivedPull[]; + opts: RowOptions; + /** stable identity: remembers "+N more" expansion across unmounts this session */ + id?: string; + /** override the standard lane cap (the repo blocks pass their own flood + * bound); omitted = laneShown's usual 30 */ + cap?: number; + /** the "+N …" button's noun — "more from server-templates" names WHOSE + * overflow this is when blocks of several repos share one lane */ + label?: string; +}) { + const tree = groupIntoTree(list); + return ( + + {tree.map(({ pull: p, depth }) => ( + + ))} + + ); +} + +// Expansion survives lens switches: re-expanding the same "+N more" on every +// tab visit, hundreds of times a day, is pure friction. Session-local on +// purpose — a fresh visit starts folded again. +const expandedIds = new Set(); + +/** + * The one truncation behavior: show `cap` items and a working "+ N more" + * button. Every capped list in the app goes through this — a count the user + * can see but not open is a lie. + */ +export function Truncated({ + children, + cap = 30, + label = 'more', + id, +}: { + children: ReactNode[]; + cap?: number; + label?: string; + /** stable identity: remembers expansion across unmounts this session */ + id?: string; +}) { + const [expanded, setExpanded] = useState(id ? expandedIds.has(id) : false); + // don't stagger a fold that was already open on mount (a lens revisit this + // session): the reveal animation is earned only on the click that opens it + const [justExpanded, setJustExpanded] = useState(false); + const expand = () => { + setExpanded(true); + setJustExpanded(true); + if (id) expandedIds.add(id); + }; + const base = children.slice(0, cap); + const extra = expanded ? children.slice(cap) : []; + const more = children.length - base.length - extra.length; + return ( + <> + {base} + {extra.length > 0 && + (justExpanded ? ( + // display:contents keeps the rows in the container's flow while + // the wrapper only carries the stagger +
+ {extra} +
+ ) : ( + extra + ))} + {more > 0 && ( + + )} + + ); +} + +/** + * The one eyebrow-label type treatment: every 11px uppercase in-list label on + * the board (Fold headers, Review's "Pick up next" band label, Ci's Check + * health label) shares this string so the tier can't drift apart one + * hand-rolled copy at a time. + */ +export const eyebrowText = 'text-[11px] font-semibold tracking-wide uppercase'; + +/** + * The board's one subsection: a collapsible eyebrow band. Word groups inside + * "Waiting on you", the rest-of-the-board ledger, a person's other PRs, Ci's + * per-check bands — every subdivision below a lane title is this component, + * so the whole board folds in a single grammar. The label speaks the same + * wait/do vocabulary the row words use: brand for your move, muted for + * everything that waits. Hovering the label opens its one-sentence gloss + * (locality — the legend stays optional reading); clicking anywhere on the + * band toggles the fold. + */ +export function Fold({ + count, + label, + gloss, + tone = 'wait', + caps = true, + showCount = true, + detail, + id, + defaultOpen = false, + children, +}: { + count: number; + label: string; + /** one hover sentence explaining the label in place */ + gloss?: string; + /** 'do' = the next step is yours (brand); 'wait' = everything else */ + tone?: 'do' | 'wait'; + /** false for case-sensitive identifiers (CI check names) the eyebrow + * transform would mangle */ + caps?: boolean; + /** hide the "· N" when the `detail` slot already states the count in its + * own words (the CI ledger's "5 of 57 runs failing") */ + showCount?: boolean; + /** right-aligned band content — a ruler, counts in words, a time */ + detail?: ReactNode; + /** stable identity: remembers this fold's open/closed choice across sessions */ + id?: string; + /** initial state when nothing is stored yet — e.g. auto-open the one fold + * that's the only content on an otherwise-quiet board */ + defaultOpen?: boolean; + children: ReactNode; +}) { + const detailsRef = useRef(null); + const stored = foldOpenStore.useValue(); + if (!count) return null; + const explicit = id ? stored[id] : undefined; + const open = explicit ?? defaultOpen; + // native
owns its own open/closed state on click; we only need to + // hear about it so the store matches. The browser also fires `toggle` on + // MOUNT for a details that renders open, so only a toggle that DIFFERS + // from the rendered state (a real user flip) is recorded — otherwise every + // defaultOpen fold would write itself into the store on first paint and + // "collapsed unless you chose otherwise" could never hold + const onToggle = (e: SyntheticEvent) => { + if (!id) return; + const next = e.currentTarget.open; + if (next === open) return; + if (foldOpenStore.get()[id] !== next) + foldOpenStore.set({ ...foldOpenStore.get(), [id]: next }); + }; + const labelInner = ( + <> + {label} + {showCount && · {count}} + + ); + return ( +
+ + + {gloss ? ( + ( + + )} + > + {/* the panel sits inside the uppercase eyebrow — undo the + treatment so the gloss reads as a normal sentence */} +

{gloss}

+
+ ) : ( + labelInner + )} + {detail && ( + + {detail} + + )} +
+
{children}
+
+ ); +} + +export function RestGroup({ + title, + sub, + children, +}: { + title?: string; + sub?: string; + children: ReactNode; +}) { + return ( +
+ {title && } + {children} +
+ ); +} diff --git a/frontend-v2/src/components/Legend.tsx b/frontend-v2/src/components/Legend.tsx new file mode 100644 index 00000000..b43a1dba --- /dev/null +++ b/frontend-v2/src/components/Legend.tsx @@ -0,0 +1,206 @@ +import type { ReactNode } from 'react'; +import { CircleHelp, Heart, Star } from 'lucide-react'; +import { HeaderIconButton } from './bits'; +import { Icon } from './Icon'; +import { Popover } from './Popover'; +import { eyebrowText } from './WordGroups'; + +/** a keycap, sized to the legend's small type */ +function Kbd({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** + * One term/definition row. The term column is right-aligned and wraps within + * its own width — a wide sample (two chips, a long flag) folds to a second + * line inside the column instead of overflowing into the definition. + */ +function Item({ term, def }: { term: ReactNode; def: ReactNode }) { + return ( +
+ + {term} + + {def} +
+ ); +} + +/** a labeled band of the key; every band after the first draws a hairline */ +function Group({ title, children }: { title: string; children: ReactNode }) { + return ( +
+
{title}
+ {children} +
+ ); +} + +/** + * The conventions card, not an encyclopedia. Everything tied to one mark + * explains itself where the mark is — the sign-off ledger keys its own pips, + * the CI / weight / age popovers carry their own rules, every group header + * glosses its word on hover, the hidden count names what it hides. What + * remains here is only what no single mark can teach: the board's invented + * vocabulary (stamp), its color grammar, the coordination signals, the query + * tokens, and the keyboard. One screen, no folds, no scrolling essays. + */ +export function Legend() { + return ( + } + > + + Reading the board + +

+ Everything explains itself where it sits: hover any mark, flag, header, or count. These + are the conventions that don’t announce themselves. +

+ + + stamp} + def="a CR or QA sign-off: a GitHub approval, or a “CR 👍” / “QA 👍” comment. A push undoes it until it’s re-confirmed" + /> + + + + + + } + def="one circle per required stamp, in order: stands, stale review, still needed. A dotted underline marks yours; click them for who signed. While a PR is a draft, blocked, or red, the re-ask waits: the author moves first" + /> + + CI + + + + } + def="CI is one circle, the machine’s mark: the red ring with a center dot, a check failed; the ring sweeping shut is a run in progress, the filled share done. On row hover a green ring closes the loop: passed. No news is good news" + /> + + + + + + + + } + def="a circle avatar is a person; a square tile is a bot or app" + /> + + + + + } + def="a corner heart marks someone on your team; the star seated on your own avatar’s rim is you" + /> + + + + + Re-stamp · 3 + + } + def="brand is your move: a brand header names an action you owe, a gray one says why a card waits. Hover the word for its meaning" + /> + + conflicts + stacked + + } + def="amber flag: act on it. Gray flag: a plain fact" + /> + + + + Claim} + def="hover a row for its verbs. Claiming adds you as a reviewer on the PR, so GitHub and the board both show you’re on it; the row moves to Waiting on you until you submit, release, or are removed" + /> + review requested} + def="someone requested your review on GitHub. Lands in Waiting on you, and the board quiets its own suggestions for that PR" + /> + your turn} + def="a review that sat too long, pointed at the best-matched reviewer. A nudge, not a lock: anyone can take it" + /> + + + + weight:xs,s} + def="review-effort classes; a row shows its class as a letter beside CR, detailed in the CR popover" + /> + has:action} + def="cards where the next move is yours" + /> + is:restamp} + def="a push undid your stamp and the PR is reviewable again" + /> + is:blocked} + def="under a dev or deploy block, whoever holds it" + /> + is:draft} + def="still a draft" + /> + is:mine} + def="opened by you" + /> + + + + /} def="focus the filter" /> + + j + k + + } + def="walk down / up the rows" + /> + ↵} def="open the selected PR" /> + c} def="copy the selected branch name" /> + +
+ ); +} diff --git a/frontend-v2/src/components/LensMenu.tsx b/frontend-v2/src/components/LensMenu.tsx new file mode 100644 index 00000000..406bb884 --- /dev/null +++ b/frontend-v2/src/components/LensMenu.tsx @@ -0,0 +1,85 @@ +import { useState } from 'react'; +import { ChevronDown } from 'lucide-react'; +import type { Lens } from '../app'; +import { Icon } from './Icon'; +import { Popover } from './Popover'; + +interface LensOption { + id: Lens; + label: string; + count?: number; +} + +/** + * The phone-width twin of app.tsx's lens tab strip: below `sm` the six tabs + * no longer fit and silently horizontal-scroll (no-scrollbar hides the + * scrollbar too, so the swipe is undiscoverable on a phone) — this renders + * the same six choices as a single Popover dropdown instead, the same + * trigger-plus-portal-panel composition StateFilter uses for its own + * single-choice list. + * + * `pick` is keyed onto the Popover so every selection remounts it, forcing + * its internal open state back to closed. Popover's `children` have no + * exposed close callback (its other consumers are multi-select filters that + * stay open across picks), and this reuses the component exactly as-is + * rather than growing its API for one caller. + */ +export function LensMenu({ + lens, + setLens, + options, + className = '', +}: { + lens: Lens; + setLens: (next: Lens) => void; + options: LensOption[]; + className?: string; +}) { + const [pick, setPick] = useState(0); + const current = options.find(o => o.id === lens); + + return ( +
+ ( + + )} + > + {options.map(({ id, label, count }) => ( + + ))} + +
+ ); +} diff --git a/frontend-v2/src/components/Logo.tsx b/frontend-v2/src/components/Logo.tsx new file mode 100644 index 00000000..d27402bf --- /dev/null +++ b/frontend-v2/src/components/Logo.tsx @@ -0,0 +1,24 @@ +/** + * The Pulldasher mark: a stag ("Dasher") whose antlers branch like a commit + * graph, each tine ending in a node — the pun and the product in one glyph. + * The path is the deer node extracted from the full brand logo (the + * "PULLDASHER" wordmark and sparkle dropped); drawn in currentColor so the + * header tints it brand and a favicon can recolor it, with evenodd fill so the + * antler-graph negative space reads. `size` is the rendered height; width + * follows the mark's own ~1.1017:1 ratio so it never distorts. + */ +export function Logo({ size = 22, className = '' }: { size?: number; className?: string }) { + return ( + + + + ); +} diff --git a/frontend-v2/src/components/NotificationPanel.tsx b/frontend-v2/src/components/NotificationPanel.tsx new file mode 100644 index 00000000..45a4da87 --- /dev/null +++ b/frontend-v2/src/components/NotificationPanel.tsx @@ -0,0 +1,402 @@ +import { useState } from 'react'; +import { ArrowLeft, Bell, Settings as SettingsIcon, X } from 'lucide-react'; +import { ago, githubUrl } from '../../../shared/format'; +import { type CheerGroup, CONFIGURABLE_TOASTS } from '../model/cheers'; +import { + type Settings as SettingsShape, + setSettings, + toggleCheerKind, + useSettings, +} from '../settings'; +import { + notificationsSupported, + notifyPermission, + requestNotifyPermission, + testNotification, + unlockSound, +} from '../notifications'; +import type { ToastRecord } from '../toasts'; +import { HeaderIconButton, QuietButton, Segmented, Switch } from './bits'; +import { Icon } from './Icon'; +import { onOpen, Popover } from './Popover'; +import { Explainer, Field } from './SettingsBits'; + +/** Tone → medallion tint, matching the toast card's own vocabulary so a nudge + * reads the same in the panel as it did when it flashed. */ +const MEDALLION: Record = { + reward: 'bg-brand-50 text-brand', + info: 'bg-brand-50 text-brand', + nag: 'bg-secondary text-ink-2', +}; + +const CHEER_GROUPS: { group: CheerGroup; title: string }[] = [ + { group: 'reward', title: 'Rewards' }, + { group: 'nudge', title: 'Nudges' }, + { group: 'author', title: 'Your PRs' }, +]; + +/** The per-kind list: every cheer/nudge, grouped, each a one-line explanation + * with its own switch. The master "Cheers & nudges" toggle still gates the + * whole system; these pick which kinds fire when it's on, so they read as + * disabled while it's off. */ +function CheerToggles({ disabled }: { disabled: boolean }) { + const muted = new Set(useSettings().mutedCheers); + return ( +
+ {CHEER_GROUPS.map(({ group, title }) => ( +
+ + {title} + + {CONFIGURABLE_TOASTS.filter(c => c.group === group).map(c => ( +
+ + {c.label} + {c.hint} + + toggleCheerKind(c.kind, next)} + /> +
+ ))} +
+ ))} +
+ ); +} + +/** + * The "Nudge settings" view: everything the Settings panel's old + * Notifications group held, moved here so notification preferences live + * behind the bell they govern instead of a separate drawer. Renders through + * the same Field/Explainer/Segmented primitives Settings.tsx uses, so the + * controls read identically in both places. + */ +function NudgeSettingsView() { + const s = useSettings(); + const [perm, setPerm] = useState(notifyPermission()); + const set = (patch: Partial) => setSettings(patch); + + const toggleNotify = async (on: boolean) => { + if (!on) { + setSettings({ notify: false }); + return; + } + let p = notifyPermission(); + if (p === 'default') p = await requestNotifyPermission(); + setPerm(p); + // this handler runs from the toggle click, so unlock audio now while we + // still have the gesture, in case Sound is already on + if (p === 'granted') unlockSound(); + setSettings({ notify: p === 'granted' }); + }; + + return ( +
+ + set({ cheers })} /> + + + + + + set({ cheerDwell })} /> + + + set({ notifyBadge })} /> + + {notificationsSupported ? ( + <> + + void toggleNotify(v)} + /> + + {perm === 'denied' && ( + + Blocked in your browser settings; allow notifications for this site to turn + them on. + + )} + + { + // a toggle click is the gesture that unlocks audio + if (notifySound) unlockSound(); + set({ notifySound }); + }} + /> + + {s.notify && perm === 'granted' && ( + testNotification()}> + Send a test notification + + )} + + ) : ( + + This browser doesn’t support desktop notifications. + + )} +
+ ); +} + +// Small typed wrappers around the house Segmented control, so each field +// above stays a one-liner instead of re-spelling its options inline. +function SegmentedOnOff({ + value, + onChange, +}: { + value: boolean; + onChange: (next: boolean) => void; +}) { + return ( + onChange(v === 'on')} + /> + ); +} + +function DwellSegmented({ + value, + onChange, +}: { + value: number | 'sticky'; + onChange: (next: number | 'sticky') => void; +}) { + return ( + onChange(v === 'sticky' ? 'sticky' : Number(v))} + /> + ); +} + +function BadgeSegmented({ + value, + onChange, +}: { + value: 'count' | 'dot' | 'none'; + onChange: (next: 'count' | 'dot' | 'none') => void; +}) { + return ( + + ); +} + +/** + * The recent-nudges panel: toasts are deliberately non-sticky, so this is + * where a nudge you missed (or dismissed) stays recoverable — the same list + * the toast stack fired, newest first, each still linking its PR. A brand dot + * on the bell counts what's landed since you last opened it. The header row's + * gear flips this same popover into a "Nudge settings" view (back arrow to + * return) holding every notification preference, so they live behind the + * bell they govern instead of a separate Settings drawer. + */ +export function NotificationPanel({ + records, + onClear, + onDismiss, +}: { + records: ToastRecord[]; + /** wipe the whole log */ + onClear: () => void; + /** drop a single entry */ + onDismiss: (id: number) => void; +}) { + // session-only "last opened" — toasts are session-only too, so there's + // nothing to persist. Everything fired after this counts as unseen. + const [lastSeen, setLastSeen] = useState(0); + const [view, setView] = useState<'nudges' | 'settings'>('nudges'); + const unseen = records.filter(r => r.at > lastSeen).length; + const badge = useSettings().notifyBadge; + const flag = unseen > 0 && badge !== 'none'; + + return ( + ( + 0 ? `recent nudges, ${unseen} new` : 'recent nudges'} + title="recent nudges" + // opening marks everything seen and resets back to the nudges + // list, so the gear detour never leaks into the next open + onClick={onOpen(t, () => { + setLastSeen(Date.now()); + setView('nudges'); + })} + className="relative" + > + {flag && + (badge === 'dot' ? ( + + ) : ( + + {unseen > 9 ? '9+' : unseen} + + ))} + + )} + > + {view === 'settings' ? ( + <> +
+ + Nudge settings +
+
+ +
+ + ) : ( + <> +
+ Recent nudges + + {records.length > 0 && ( + + )} +
+ {records.length === 0 ? ( +
No nudges yet.
+ ) : ( +
    + {records.map(r => ( +
  • + + {r.toast.icon} + + + + + {r.toast.title} + + + {ago(r.at / 1000)} + + + {r.toast.pull && ( + + #{r.toast.pull.number} + {r.toast.pull.title ? ` ${r.toast.pull.title}` : ''} + + )} + {r.toast.body && ( + {r.toast.body} + )} + + +
  • + ))} +
+ )} + + )} +
+ ); +} diff --git a/frontend-v2/src/components/Popover.tsx b/frontend-v2/src/components/Popover.tsx new file mode 100644 index 00000000..e2ded01e --- /dev/null +++ b/frontend-v2/src/components/Popover.tsx @@ -0,0 +1,139 @@ +import { useLayoutEffect, useState, type ReactNode, type Ref } from 'react'; +import { createPortal } from 'react-dom'; +import { usePopover } from './usePopover'; + +export interface PopoverTriggerProps { + ref: Ref; + 'aria-haspopup': 'dialog'; + 'aria-expanded': boolean; + onClick: () => void; + /** present only with `hoverTriggerOnly`: the hover handlers move off the + * root and onto the trigger element itself */ + onMouseEnter?: () => void; + onMouseLeave?: () => void; +} + +/** + * Wraps a trigger's onClick so `fn` runs once, only on the transition from + * closed to open — not on every toggle. NotificationPanel (mark notifications + * seen) only cares about the open edge; the toggle itself stays the house + * Popover's. + */ +export function onOpen(t: PopoverTriggerProps, fn: () => void): () => void { + return () => { + const wasOpen = t['aria-expanded']; + t.onClick(); + if (!wasOpen) fn(); + }; +} + +/** + * The house popover: a trigger button plus a role=dialog panel, sharing the + * click-away / Escape / focus discipline in usePopover. NotificationPanel, + * Legend, the sign-off ledger, and the filter components (StateFilter, + * WeightFilter, RepoFilter, PeopleFilter, HiddenPanel) all render through + * this, so the panel chrome and ARIA plumbing live in one place instead of + * being hand-rolled per caller. + * (Settings is deliberately NOT one of these — it's a portal drawer with a + * scrim, a different dismissal model.) + * + * The panel is portaled to document.body and positioned `fixed` off the + * trigger's rect, so it can't be clipped by an ancestor's `overflow: hidden` + * (the row list is a rounded, clipped surface) or trapped under a row's + * stacking context. It follows the trigger on scroll/resize. + * + * The trigger is a render prop: spread the given props onto your own + + + ); +} diff --git a/frontend-v2/src/components/Row.tsx b/frontend-v2/src/components/Row.tsx new file mode 100644 index 00000000..8130ef09 --- /dev/null +++ b/frontend-v2/src/components/Row.tsx @@ -0,0 +1,801 @@ +import { memo, useState, type ReactNode } from 'react'; +import { + AlarmClock, + Copy, + Diamond, + EllipsisVertical, + Eye, + EyeOff, + Hand, + Heart, + RefreshCw, +} from 'lucide-react'; +import type { DerivedPull } from '../../../shared/model/status'; +import { isIterating, lastPushEpoch } from '../../../shared/model/status'; +import { type Claim, rowNote } from '../model/actions'; +import { matchedRegions } from '../model/regions'; +import { claimFor } from '../model/reviewers'; +import type { ParentRef } from '../model/stack'; +import { ago, epoch, pullKey, rowDomId, shortRepo } from '../../../shared/format'; +import { setRepoPref, toggleHiddenPerson, useSettings } from '../settings'; +import { + claimReview, + isFresh, + isSnoozed, + refreshPull, + releaseReview, + snoozePull, + unsnoozePull, + usePulldasher, +} from '../store'; +import { AgeStamp, AgeBaseline } from './age'; +import { DiffSize, railTriggerClass, RepoRef, WEIGHT_WORD } from './bits'; +import { CardShell } from './Card'; +import { CiStatus, SigPips } from './pips'; +import { Icon } from './Icon'; +import { StatePopover } from './StatePopover'; +import { Popover } from './Popover'; + +export interface RowOptions { + /** compact density: one-line rows, smaller avatar, tighter spacing */ + compact?: boolean; + /** rows a lane shows before folding; 0 = no cap. Falls back to the lane's + * own default when unset (old saved settings). */ + laneCap?: number; + me: string; + lastSeen: number; + onPerson?: (login: string) => void; + /** age thresholds from user settings (fall back to the model's) */ + ageWarnDays?: number; + ageRotDays?: number; + /** which clock the age numeral shows (settings.ageDisplay) */ + ageDisplay?: 'opened' | 'updated'; + /** offer the Snooze verb on rows — true only on the Review lens, the + * one lens a snooze quiets */ + showSnooze?: boolean; + /** the board's longest-open pull, in days — the age baseline's full + * track; every row's line is a fraction of the oldest */ + maxAgeDays?: number; + /** whole-board parent lookup (model/stack.ts's buildParentLookup, memoized + * once in app.tsx): resolves a dependent pull's parent even when it's + * absent from the CURRENT list (a different lane, a hidden repo, another + * lens' scope), so the 'stacked' flag can name it instead of just saying + * "based on ". */ + parentOf?: (p: DerivedPull) => ParentRef | null; + /** per-repo reviewer pool for the deterministic turn rotation + * (model/rotation.ts), memoized once in app.tsx over the whole board. */ + pools?: ReadonlyMap; + /** pull key → whose turn it is (the best-fit reviewer for a starved, + * unclaimed PR), computed once in app.tsx so the row just looks it up. */ + turns?: ReadonlyMap; + /** one plain-words line on why this pull sits where it does in a ranked + * lane (Review's queue / Needs QA) — rendered in the state popover, never + * inline on the card. */ + rankReason?: (p: DerivedPull) => string | null; + /** the two "In your code regions" lanes (Review, Team) set this true on + * their own rows: the lane header already says "this is your region," so + * the region mark would be redundant on every card inside it. */ + hideRegionMark?: boolean; + /** bot detection for the avatar's square-tile shape (app.tsx's config-fed + * isBotLogin — the row can't know the config's extra-bots list itself) */ + isBotAuthor?: (login: string) => boolean; +} + +/** + * A fresh row is a brand-new PR (solid dot) or an updated one (ring), and + * opening it clears the mark for the session. + */ +function freshKind(p: DerivedPull, opts: RowOptions): 'new' | 'updated' | null { + if (!isFresh(p.data, opts.lastSeen)) return null; + return epoch(p.data.created_at) > opts.lastSeen ? 'new' : 'updated'; +} + +// The background flash runs once per pull per session, not on every lens +// switch that remounts the row. Recording during render is deliberate: memo +// keeps re-renders rare, and a double-record is harmless. +const flashed = new Set(); +function flashOnce(key: string, fresh: boolean): boolean { + if (!fresh || flashed.has(key)) return false; + flashed.add(key); + return true; +} + +interface Flag { + key: string; + /** 'warn' = act on it (amber); 'note' = a neutral fact (muted) */ + tone: 'warn' | 'note'; + label: string; + detail: ReactNode; +} + +/** + * The row's secondary annotations, gathered in one place: conflicts, stacked, + * holds, in-flight CI, iterating. (Age lives on the row's baseline, not a + * flag — one mark per fact.) They're not the status (the badge is) + * and not your action (the note is), so they read as quiet colored labels, + * not badges — amber only for the ones you act on, muted gray for plain facts. + * + * `depth` is the row's stack-nesting depth (model/stack.ts's groupIntoTree): + * depth > 0 means the parent is already visible right above this row, so the + * 'stacked' flag would be redundant — geometry already says it. At depth 0, + * `orphanParent` (the whole-board lookup, when it resolves) upgrades the + * flag to name the parent instead of only describing the base ref. + */ +function rowFlags( + pull: DerivedPull, + showIterating: boolean, + depth: number, + orphanParent: ParentRef | null +): Flag[] { + const p = pull; + const flags: Flag[] = []; + if (p.conflict && p.status !== 'unmergeable') + flags.push({ + key: 'conflicts', + tone: 'warn', + label: 'conflicts', + detail: 'Merge conflicts with the base branch; the author needs to rebase.', + }); + if (p.deployBlockedBy.length > 0 && p.status !== 'deploy_block') + flags.push({ + key: 'deploy-block', + tone: 'warn', + label: 'deploy block', + detail: `${p.deployBlockedBy.join(', ')} put a deploy block on it; don’t ship without asking.`, + }); + if (p.externalBlock) + flags.push({ + key: 'external', + tone: 'warn', + label: 'external', + detail: 'Blocked on something outside this repo.', + }); + if (p.dependent && p.status !== 'unmergeable' && depth === 0) { + flags.push( + orphanParent + ? { + key: 'stacked', + tone: 'note', + label: `stacked on #${orphanParent.number}`, + detail: ( + <> + Based on {p.data.base.ref}; lands with its parent,{' '} + + “{orphanParent.title}” #{orphanParent.number} + + . + + ), + } + : { + key: 'stacked', + tone: 'note', + label: 'stacked', + detail: `Based on ${p.data.base.ref}, not the main branch; it lands with its parent.`, + } + ); + } + if (p.mergeUnknown && p.status === 'ready') + flags.push({ + key: 'merge', + tone: 'note', + label: 'merge check', + detail: 'GitHub hasn’t confirmed this merges cleanly yet.', + }); + if (p.ci === 'pending' && p.status !== 'ci_pending') + flags.push({ + key: 'ci', + tone: 'note', + label: 'CI running', + detail: 'CI checks are still running on the latest push.', + }); + if (showIterating) { + const pushedAt = lastPushEpoch(p); + flags.push({ + key: 'iterating', + tone: 'note', + label: 'recent changes', + detail: `Pushed ${ago(pushedAt)} ago; it may still be moving, so hold off.`, + }); + } + return flags; +} + +/** + * The flag cluster's drill-down: labels inline for scanning, the plain- + * English meaning one hover away — the same pattern the CR/QA pips use, so + * no flag hides its meaning behind a native tooltip. Both densities show the + * full label list (compact wraps rather than abbreviating); with no flags + * there's nothing to explain and no trigger to show. + */ +function RowDetails({ flags }: { flags: Flag[] }) { + if (!flags.length) return null; + return ( + ( + + )} + > + {flags.map(f => ( + + + + {f.label} {f.detail} + + + ))} + + ); +} + +/** "a, b, and c" for the region-match popover sentence — plain-English list, + * no Oxford-comma debate for the two-item case. */ +function joinAnd(items: string[]): string { + if (items.length <= 1) return items.join(''); + if (items.length === 2) return `${items[0]} and ${items[1]}`; + return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}`; +} + +/** + * The per-row actions, shared by the desktop hover cluster and the mobile + * kebab menu so the two surfaces can never disagree on behavior. + */ +function useRowActions(pull: DerivedPull) { + const [copied, setCopied] = useState(false); + const [spinning, setSpinning] = useState(false); + return { + copied, + spinning, + copy: () => { + void navigator.clipboard.writeText(pull.data.head.ref); + setCopied(true); + setTimeout(() => setCopied(false), 1200); + }, + snooze: () => snoozePull(pullKey(pull.data)), + unsnooze: () => unsnoozePull(pullKey(pull.data)), + refresh: () => { + refreshPull(pull.data.repo, pull.data.number); + setSpinning(true); + setTimeout(() => setSpinning(false), 600); + }, + claim: () => claimReview(pull.data), + release: () => releaseReview(pull.data), + }; +} + +/** + * The row's two workflow buttons — Snooze and Claim — floating left of the + * data rail (zero standing geometry; the right edge stays the facts'). + * Each is its own chip with one rule: a button STANDS when it records a + * choice you made — a claimed row always shows "Release", a snoozed row + * always shows "Unsnooze", both in place with no hover — and reveals on + * row hover/focus when it merely OFFERS one ("Claim", "Snooze"). If both + * states are yours, both chips stand. Opacity-only reveal (motion stays + * reserved for state changes), pointer-events following it so a hidden + * offer can't steal a click. Touch has no hover: offers live in the kebab + * there, while standing exits remain visible. Snooze renders only where + * it acts (Review). + */ +function VerbDock({ + pull, + me, + claim, + showSnooze, +}: { + pull: DerivedPull; + me: string; + claim: Claim | null; + showSnooze?: boolean; +}) { + const a = useRowActions(pull); + const { snoozed } = usePulldasher(); + const snoozedNow = isSnoozed(pull.data, snoozed); + const mine = claim?.login === me; + const claimable = pull.data.user.login !== me && !mine; + const showSnoozeChip = showSnooze || snoozedNow; + const showClaimChip = claimable || mine; + if (!showSnoozeChip && !showClaimChip) return null; + // icon chips: one glyph per verb (clock = snooze, hand = claim), with the + // standing/offer split carried by visibility and aria-pressed — the title + // and aria-label carry the words the glyph gave up + const chip = + 'hit pressable inline-flex items-center justify-center rounded-md border-0 bg-muted px-1.5 py-1 whitespace-nowrap'; + const snoozeWords = snoozedNow + ? 'wake it: back on your Review tab now' + : 'snooze: off your Review tab until tomorrow or until it changes'; + const claimWords = mine + ? 'release your claim' + : claim + ? `claim review, currently ${claim.login}'s` + : 'claim this review: adds you as a reviewer on the PR itself, so GitHub and the board both show you’re on it'; + return ( + + {showSnoozeChip && ( + + )} + {showClaimChip && ( + + )} + + ); +} + +/** + * The overflow row menu: below 720px every row keeps a quiet, always-visible + * kebab that opens a tap-friendly labeled menu (the house click-to-open + * popover — no hover in the path). At desktop widths it stays hidden EXCEPT + * in narrow columns (≤520px containers, Classic's): there the four-icon + * hover cluster would crowd the wrapped rail, so it collapses and this kebab + * takes over, revealed on row hover / focus (styles.css .pd-kebab). In wide + * lanes the hover cluster remains the desktop home and the kebab stays gone. + */ +function RowActionsKebab({ + pull, + claim, + showSnooze, +}: { + pull: DerivedPull; + claim: Claim | null; + showSnooze?: boolean; +}) { + const a = useRowActions(pull); + const settings = useSettings(); + const { me, snoozed } = usePulldasher(); + const snoozedNow = isSnoozed(pull.data, snoozed); + const repo = pull.data.repo; + const author = pull.data.user.login; + const repoLabel = shortRepo(repo); + const isHiddenRepo = settings.repoPrefs[repo] === 'hide'; + const claimedByMe = claim?.login === me; + const item = + 'flex w-full items-center gap-2 rounded-md border-0 bg-transparent px-2 py-2 text-left text-xs text-ink-2 hover:bg-muted'; + return ( + ( + + )} + > + {/* never offered on your own pull — you don't review yourself */} + {author !== me && ( + + )} + {showSnooze && ( + + )} +
+ + +
+ + {/* never offered for your own pulls — you can't hide yourself from + your own board */} + {author !== me && ( + + )} + + ); +} + +/** + * The fixed-width label a rail slot leads with — CI, CR, QA, and the weight + * letter all share this exact 18px/11px/font-medium slot, so whichever ones + * a row shows still land at the same x down a board. aria-hidden: the + * interactive mark beside it (a pip cluster or a popover trigger) carries + * the accessible name. + */ +function RailLabel({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** + * The weight drill-down, now a section of the CR ledger panel (one popover + * for the whole CR cluster — label, letter, and pips are a single door). + * Content is the old standalone weight popover's: the effort word, the exact + * diff, how the letter is decided, and the filter action. + */ +function WeightPanelSection({ pull }: { pull: DerivedPull }) { + const { weight } = pull; + const d = pull.data; + return ( +
+ + review effort: {WEIGHT_WORD[weight]} + + + + + {/* how the letter is decided is reference, not the headline — folded + away so the panel leads with the effort word and the diff */} +
+ + Weight details + + + From the org’s size label when the PR has one, else the diff: 50 / 150 / 600 / 1500 + lines step XS through XL, one class up past 15 files. A pointer, not a verdict. + +
+
+ ); +} + +/** + * The metric rail every row ends on, in one order everywhere: CI, then CR + * (label, weight letter, sign-off pips), then QA (label, pips). One line, + * vertically centered — the weight ruler that used to run underneath is + * gone; the letter says the same thing inside the CR cluster instead. + * Age lives on the row's own baseline, not in the rail (see RowImpl). Right- + * anchored and fixed-geometry, so it reads as vertical columns down any + * lens. Raised above the card's click layer so the sign-off popovers still + * open. + */ +function MetricRail({ + pull, + opts, + claim, +}: { + pull: DerivedPull; + opts: RowOptions; + claim: Claim | null; +}) { + const d = pull.data; + const me = opts.me; + return ( + + + {/* one instrument, humans first: CR (label, weight letter, pips — + one door into one panel), then QA, then the machine's circle at + the rail's end, where its optional render (green only on hover) + can't shift the human marks, and last the age numeral capping + the row. */} + + CR + {/* the owner's sketch was "CR · S ✓✓": the middot keeps the + label and the size letter from fusing at 11px */} + + · + + + {pull.weight} + + + } + panelExtra={} + /> + QA} + /> + + {/* the age numeral caps the rail — the row's far-right column, a + whisper in the age line's own tint until the row is hovered + (styles.css .pd-age-num). Which clock it shows is the user's + call (settings.ageDisplay); both clocks stay in its popover. */} + + + + ); +} + +function RowImpl({ + pull, + opts, + depth = 0, +}: { + pull: DerivedPull; + opts: RowOptions; + /** stack-nesting depth from model/stack.ts's groupIntoTree (0 = top-level + * or not rendered through a stack-aware list). Per-row data, not an + * option — it varies row to row within the same list. */ + depth?: number; +}) { + const d = pull.data; + const key = pullKey(d); + const fresh = freshKind(pull, opts); + // who's claimed to review this pull, and whose turn the rotation names — + // both optional, so a lens that hasn't threaded them (yet) just sees null + // and rowNote falls back to its base (pre-coordination) note + const claim = claimFor(d); + const turn = opts.turns?.get(key) ?? null; + const poolSize = opts.pools?.get(d.repo)?.length ?? 0; + // the viewer-relative note (model/actions.ts): never both-null for an open + // pull, so the one badge below always has something to say + const note = rowNote(pull, opts.me, { claim, turn }); + // the wait badge may itself carry a "last commit …" — don't say it twice + const showIterating = isIterating(pull) && !(note.context ?? '').includes('last commit'); + // the smallest clean marker for a teammate: a tiny heart over their + // avatar, so the row itself says "one of your people" without a trip to + // the kebab menu + const settings = useSettings(); + const rosters = settings.teams.filter(t => t.members.includes(d.user.login)).map(t => t.name); + // which of your code regions this pull matched (why it floated to the top) + const regions = matchedRegions(pull, settings.codeRegions); + // only worth asking the whole-board lookup when this row is stacked but + // rendering flat (its parent isn't visible right above it already) + const orphanParent = pull.dependent && depth === 0 ? (opts.parentOf?.(pull) ?? null) : null; + return ( + 0 && + d.user.login !== opts.me && ( + + + + ) + } + meta={ + // ONE badge per card: your move (brand pill) when you have one, the + // wait-reason (muted pill) when you don't — never both, never a + // separate status badge. Everything the old chips said (status, + // fresh/updated, "review requested", the context sentence) lives one + // hover away in the state popover the badge opens. The goal is a + // one-glance "can I act on this?" scan down any column. + <> + {/* the badge is gone — lists group by rowWord instead — so the + repo#number ref is now the card's one door into the full + state popover: a stable position, always present, unlike a + badge that could be either color or absent. */} + + + + {regions.length > 0 && !opts.hideRegionMark && ( + // the brand diamond leads and the matched region is NAMED in + // quiet ink beside it — which of your regions hit must read + // without a hover (a bare glyph hid it; owner call). No + // border, no fill: plain meta text, not a chip, so "blue = + // your move" stays undiluted. The full sentence keeps its + // hover door. + ( + + )} + > + + Touches {joinAnd(regions)},{' '} + {regions.length > 1 ? 'code regions' : 'a code region'} you flagged in + Settings. + + + )} + + + } + rail={} + edge={ + + } + /> + ); +} + +/** + * Rows re-render only when their pull is re-derived (the store caches + * derive() per PullData reference) or an option actually changes — a burst + * of pullChange events must not reconcile 180 untouched rows. There's no + * separate opts.claims to check any more: a claim lives on the pull itself + * (review_requests), so claiming/releasing arrives as a new pull reference + * (a.pull !== b.pull) and the row re-renders through that, same as any other + * pull update. + */ +export const Row = memo( + RowImpl, + (a, b) => + a.pull === b.pull && + a.depth === b.depth && + a.opts.compact === b.opts.compact && + a.opts.me === b.opts.me && + a.opts.lastSeen === b.opts.lastSeen && + a.opts.onPerson === b.opts.onPerson && + a.opts.ageWarnDays === b.opts.ageWarnDays && + a.opts.maxAgeDays === b.opts.maxAgeDays && + a.opts.ageRotDays === b.opts.ageRotDays && + a.opts.ageDisplay === b.opts.ageDisplay && + a.opts.showSnooze === b.opts.showSnooze && + a.opts.parentOf === b.opts.parentOf && + a.opts.pools === b.opts.pools && + a.opts.turns === b.opts.turns && + a.opts.isBotAuthor === b.opts.isBotAuthor +); diff --git a/frontend-v2/src/components/SavedFiltersPanel.tsx b/frontend-v2/src/components/SavedFiltersPanel.tsx new file mode 100644 index 00000000..3fcc32b0 --- /dev/null +++ b/frontend-v2/src/components/SavedFiltersPanel.tsx @@ -0,0 +1,426 @@ +import { + useEffect, + useId, + useRef, + useState, + type FocusEvent, + type KeyboardEvent, + type RefObject, +} from 'react'; +import { Check, Pin, Search, Trash2 } from 'lucide-react'; +import { + applySavedFilter, + deleteFilter, + describeHash, + findSavedName, + saveFilter, + setPinned, + useAllSavedFilters, + type SavedFilter, +} from '../model/savedFilters'; +import { QuietButton, textInputClass } from './bits'; +import { FilterTrigger, hoverRowClass } from './filters/shared'; +import { Icon } from './Icon'; +import { Popover } from './Popover'; +import { commitKeyHandler } from './useCommitOnEnter'; +import { useArmedConfirm } from './useArmedConfirm'; + +/** + * One saved-filter row, shared by the query-box panel and the header "Saved" + * menu — the two doors into the same list, so a row must read identically in + * both: name + a muted one-line gloss of what it narrows, a quiet brand ✓ + * when it IS the view on screen, and a remove affordance that only shows on + * row hover/focus. There is exactly one kind of row: the starters are + * planted into the store on first load and are as removable as anything the + * user saved. + */ +function SavedFilterRow({ + filter, + id, + active, + current, + onApply, + onRemove, +}: { + filter: SavedFilter; + id?: string; + /** keyboard-highlighted (query panel only) */ + active?: boolean; + /** this row's hash IS the view on screen */ + current?: boolean; + onApply: () => void; + /** absent for a roster's auto search — it lives and dies with the roster + * (Team lens), so this panel offers no delete for it */ + onRemove?: () => void; +}) { + // arm-then-confirm, same pattern as Settings' "Clear settings": a saved + // filter can be a hand-tuned query, so a single misclick on the ✕ must not + // erase it — first click arms for 4s, the second actually removes + const { armed, run } = useArmedConfirm(); + return ( +
+ + {/* a pinned pin is standing state, so it stays visible; an unpinned + one is an offer and reveals on row hover like the delete */} + + { + e.stopPropagation(); + setPinned(filter, !filter.pinned); + }} + aria-pressed={!!filter.pinned} + aria-label={ + filter.pinned + ? `unpin ${filter.name} from the filter bar` + : `pin ${filter.name} to the filter bar` + } + title={ + filter.pinned ? 'unpin from the filter bar' : 'pin to the filter bar as a chip' + } + > + + + + {onRemove && ( + + { + e.stopPropagation(); + run(onRemove); + }} + aria-label={ + armed + ? `confirm deleting saved filter ${filter.name}` + : `delete saved filter ${filter.name}` + } + title={armed ? 'click again to delete' : 'delete this saved filter'} + > + {armed ? ( + sure? + ) : ( + + )} + + + )} +
+ ); +} + +/** + * The save affordance, identical in both doors and honest about state: + * nothing narrowing → a quiet hint; an unsaved view on screen → "Save this + * view…" opening an inline name field; a view that IS saved → its name, so + * the button never offers to duplicate what already exists. + */ +function SaveCurrentView({ + currentHash, + sessionActive, + savedAs, + onSaved, +}: { + currentHash: string; + sessionActive: boolean; + /** the name this view is already saved under, if any */ + savedAs: string | null; + /** close the containing panel after a successful save */ + onSaved?: () => void; +}) { + const [name, setName] = useState(null); + const nameRef = useRef(null); + useEffect(() => { + if (name != null) nameRef.current?.focus(); + }, [name != null]); + + if (!sessionActive) { + return ( +
+ Set up filters, then save the view here. +
+ ); + } + if (savedAs) { + return ( +
+ + + + This view is saved as “{savedAs}”. +
+ ); + } + if (name == null) { + return ( + + ); + } + const save = () => { + const trimmed = name.trim(); + if (!trimmed) return; + saveFilter(trimmed, currentHash); + setName(null); + onSaved?.(); + }; + return ( +
+ setName(e.target.value)} + onKeyDown={commitKeyHandler({ onCommit: save, onCancel: () => setName(null) })} + placeholder="Name this view" + aria-label="Name this view" + className={`min-w-0 flex-1 px-2 ${textInputClass}`} + /> + + Save + +
+ ); +} + +/** + * The query box's own door into saved filters: focus the EMPTY filter input + * and the list drops under it — arrow keys highlight a row, Enter applies + * it, Escape closes without losing focus. Typing anything closes it and the + * input goes back to being a plain filter box. Same rows, same save + * affordance as the header's Saved menu: one feature, two doors. + * + * Hand-rolled rather than built on the house Popover: Popover's usePopover + * pins/unpins on trigger *clicks* and returns focus to a trigger *button* on + * Escape, both wrong for a text input that needs to open on focus, stay open + * while typing a save-name in its own footer field, and never steal focus + * from itself. It reuses Popover's visual vocabulary (the `popover` + * animation class, the same border/shadow/radius) so it still reads as the + * same kind of panel. + */ +export function SavedFiltersInput({ + query, + setQuery, + inputRef, + currentHash, + sessionActive, + inputProps, +}: { + query: string; + setQuery: (next: string) => void; + inputRef: RefObject; + /** the hash a save right now would capture (buildHash of the live state) */ + currentHash: string; + /** whether the current session has anything worth bookmarking (mirrors + * FilterChips' hasActiveFilters) — gates the "Save this view…" row */ + sessionActive: boolean; + /** the search input's existing aria-label/placeholder/title/className, so + * this wrapper doesn't have to restate app.tsx's exact copy */ + inputProps: { + 'aria-label': string; + placeholder: string; + title: string; + className: string; + }; +}) { + const saved = useAllSavedFilters(); + const savedAs = sessionActive ? findSavedName(saved, currentHash) : null; + + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + const listboxId = useId(); + + useEffect(() => { + if (!open) setActiveIndex(-1); + }, [open]); + + const apply = (hash: string) => { + applySavedFilter(hash); + setOpen(false); + }; + + // one blur handler on the wrapper covers the input, every row button, and + // the save-name field: focus moving anywhere still inside the wrapper + // (e.g. into the save-name input) keeps the panel open, and only a focus + // that lands truly outside closes it. This is the "focus/blur with a + // click-away" the panel needs in place of Popover's click-toggle model. + const onWrapperBlur = (e: FocusEvent) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setOpen(false); + }; + + const onInputKeyDown = (e: KeyboardEvent) => { + if (!open) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIndex(i => Math.min(i + 1, saved.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex(i => Math.max(i - 1, -1)); + } else if (e.key === 'Enter') { + if (activeIndex >= 0 && saved[activeIndex]) { + e.preventDefault(); + apply(saved[activeIndex].hash); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + setOpen(false); + } + }; + + const activeId = activeIndex >= 0 ? `${listboxId}-${activeIndex}` : undefined; + // an empty list with nothing to save would open an empty box — stay shut + const canOpen = saved.length > 0 || sessionActive; + + return ( + + + { + const next = e.target.value; + setQuery(next); + if (next) setOpen(false); + }} + onFocus={() => { + if (!query && canOpen) setOpen(true); + }} + onKeyDown={onInputKeyDown} + role="combobox" + aria-expanded={open} + aria-controls={listboxId} + aria-activedescendant={activeId} + aria-autocomplete="none" + className={inputProps.className} + /> + {open && ( +
+ {saved.map((f, i) => ( + apply(f.hash)} + onRemove={f.auto ? undefined : () => deleteFilter(f.name)} + /> + ))} + {sessionActive && ( +
+ setOpen(false)} + /> +
+ )} +
+ )} +
+ ); +} + +/** + * The header's "Saved ▾" trigger — the feature's permanent home, always in + * the bar: the full list plus the save affordance, honest about whether the + * view on screen is already saved. Wears the same quiet FilterTrigger the + * four dimension triggers wear, so the bar reads as one family. + */ +export function SavedFiltersMenu({ + currentHash, + sessionActive, +}: { + currentHash: string; + sessionActive: boolean; +}) { + const saved = useAllSavedFilters(); + const savedAs = sessionActive ? findSavedName(saved, currentHash) : null; + + return ( +
+ ( + + )} + > + {saved.map(f => ( + applySavedFilter(f.hash)} + onRemove={f.auto ? undefined : () => deleteFilter(f.name)} + /> + ))} + {saved.length === 0 && ( +
Nothing saved yet.
+ )} +
+ +
+
+
+ ); +} diff --git a/frontend-v2/src/components/Settings.tsx b/frontend-v2/src/components/Settings.tsx new file mode 100644 index 00000000..177b0549 --- /dev/null +++ b/frontend-v2/src/components/Settings.tsx @@ -0,0 +1,477 @@ +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Settings as SettingsIcon, Trash2, X } from 'lucide-react'; +import { clearStoredPrefs } from '../storage'; +import { refreshAll, usePulldasher } from '../store'; +import { + addCodeRegion, + removeCodeRegion, + setLaneCapForLens, + type Settings as SettingsShape, + setSettings, + useSettings, +} from '../settings'; +import { LENS_LABELS, type Lens } from '../lens'; +import { HeaderIconButton, QuietButton, Segmented } from './bits'; +import { Icon } from './Icon'; +import { Explainer, Field, Group, NumberField } from './SettingsBits'; +import { useArmedConfirm } from './useArmedConfirm'; +import { commitKeyHandler } from './useCommitOnEnter'; + +const LENS_OPTIONS: [string, string][] = Object.entries(LENS_LABELS); + +// Stats has no lanes, so it's left out of the per-lens lane-length list +// (unlike LENS_OPTIONS above, which covers every tab including Stats) — the +// membership stays its own explicit list, but each label still comes from +// the shared map so it can't drift from LENS_OPTIONS' wording. +const LANE_CAP_LENSES: Lens[] = ['review', 'mine', 'team', 'classic', 'ci']; +const LANE_CAP_LENS_OPTIONS: [string, string][] = LANE_CAP_LENSES.map(id => [id, LENS_LABELS[id]]); + +const LANE_CAP_OPTIONS: [string, string][] = [ + ['default', 'Default'], + ['10', '10'], + ['25', '25'], + ['50', '50'], + ['0', 'No cap'], +]; + +/** One lens' lane-length override row, inside the "Different length per view" + * disclosure. "Default" clears the override so the lens falls back to the + * global Lane length above. */ +function LaneCapByLensRow({ lensId, label }: { lensId: string; label: string }) { + const cap = useSettings().laneCapByLens[lensId]; + return ( + + setLaneCapForLens(lensId, v === 'default' ? null : Number(v))} + /> + + ); +} + +/** Free-text editor for the code regions that float matching PRs to the top of + * the review queue. Arbitrary strings (not a known set), so it's a plain input + * plus removable chips, not a candidate picker. */ +function CodeRegionsGroup() { + const regions = useSettings().codeRegions; + const [draft, setDraft] = useState(''); + const add = () => { + addCodeRegion(draft); + setDraft(''); + }; + return ( + + + Areas you own or care about. A PR whose title, description, labels, branch, or repo + contains one floats to the top of your review queue. + +
+ setDraft(e.target.value)} + onKeyDown={commitKeyHandler({ onCommit: add, onCancel: () => setDraft('') })} + placeholder="e.g. Growthbook, Shopify, Diagrams" + aria-label="add a code region" + className="min-w-0 flex-1 rounded-md border border-line bg-surface px-2 py-1 text-sm outline-none focus-visible:border-brand" + /> + + Add + +
+ {regions.length > 0 && ( +
    + {regions.map(r => ( +
  • + + {r} + + +
  • + ))} +
+ )} +
+ ); +} + +export function Settings({ + onGoToTeam, +}: { + /** switch the board to the Team lens and close this panel — the "Edited + * on the board" pointer line's action. The team picker and repo manager + * embeds are gone (Team view and the header's Repos filter own those + * now), so this is the only cross-surface wiring this panel needs. */ + onGoToTeam: () => void; +}) { + const [open, setOpen] = useState(false); + const s = useSettings(); + const { refreshProgress } = usePulldasher(); + const panelRef = useRef(null); + const triggerRef = useRef(null); + const [refreshNote, setRefreshNote] = useState(''); + const { armed: armReset, run: runReset } = useArmedConfirm(); + + // open on demand — the code-regions tip's "Set them up" action dispatches + // this so a reader can jump straight here from the board + useEffect(() => { + const openIt = () => setOpen(true); + window.addEventListener('pd2:open-settings', openIt); + return () => window.removeEventListener('pd2:open-settings', openIt); + }, []); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + // aria-modal promises a focus trap; without this, Tab walks out of + // the dialog into the live board behind the scrim + if (e.key === 'Tab' && panelRef.current) { + const focusables = panelRef.current.querySelectorAll( + 'button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])' + ); + if (!focusables.length) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const active = document.activeElement; + if (e.shiftKey && (active === first || active === panelRef.current)) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } + } + }; + document.addEventListener('keydown', onKey); + // a modal locks the page behind it: the scrim already blocks clicks, + // this blocks scroll + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + // focus the panel so Escape and tabbing land inside it + panelRef.current?.focus(); + return () => { + document.removeEventListener('keydown', onKey); + document.body.style.overflow = prevOverflow; + }; + }, [open]); + + // return focus to the cog when the panel closes — but only after a real + // open->close transition, not on initial mount (open starts false, and + // stealing focus to the gear on page load with no user interaction) + const didOpenRef = useRef(false); + useEffect(() => { + if (open) didOpenRef.current = true; + else if (didOpenRef.current) triggerRef.current?.focus?.(); + }, [open]); + + const set = (patch: Partial) => setSettings(patch); + + return ( + <> + setOpen(o => !o)} + /> + {open && + createPortal( +
+
setOpen(false)} + aria-hidden + /> +
+ {/* z-10: children further down the panel (avatars, the + code-regions Add button — anything position:relative + via .hit/.pressable) paint above a sticky header with + no z-index of its own once they scroll under it. The + overlay above is already z-[100], so z-10 in here + only has to beat panel content. */} +
+ Settings + +
+ + + + set({ theme })} + /> + + + set({ density })} + /> + + + set({ defaultLens })} + /> + + + + + + set({ laneCap: Number(v) })} + /> + + + + Classic can show everything while Review stays short. + +
+ {LANE_CAP_LENS_OPTIONS.map(([id, label]) => ( + + ))} +
+
+ + set({ repoQueueCap: Number(v) })} + /> + + + set({ ageWarnDays })} + /> + + + set({ ageDisplay })} + /> + + + set({ selfReview: v === 'on' })} + /> + + + set({ claimWarnMins: Number(v) })} + /> + + + set({ openPrsNewTab: v === 'new' })} + /> + + + set({ hoverDelayMs: Number(v) })} + /> + +
+ + + + +
+ + Teams: build your rosters on the Team view. + + { + onGoToTeam(); + setOpen(false); + }} + > + Open Team view + +
+ + Repos: hide and reveal from the Repos filter in the header. + +
+ +
+ + + Re-fetch every open PR from GitHub now, instead of waiting for the + next webhook. The board updates as each one comes back. + +
+ { + // live progress (store.refreshProgress) takes over + // from here; the local note only covers the no-op + if (refreshAll() === 0) { + setRefreshNote('nothing to refresh'); + setTimeout(() => setRefreshNote(''), 2500); + } + }} + > + Refresh all + + {/* role=status stays mounted so the announcement fires + when the text lands — a screen reader hears the + confirmation, not just sighted users */} + + {refreshProgress + ? refreshProgress.done === refreshProgress.total + ? `refreshed ${refreshProgress.total}` + : `refreshing ${refreshProgress.done} of ${refreshProgress.total}…` + : refreshNote} + +
+ + + Reset every preference on this browser (theme, filters, hidden repos + and people, last-seen marker) back to defaults. This can’t be undone. + +
+ +
+
+
+
+
, + document.body + )} + + ); +} diff --git a/frontend-v2/src/components/SettingsBits.tsx b/frontend-v2/src/components/SettingsBits.tsx new file mode 100644 index 00000000..2b84c556 --- /dev/null +++ b/frontend-v2/src/components/SettingsBits.tsx @@ -0,0 +1,95 @@ +import { type ReactNode, useEffect, useState } from 'react'; +import { ChevronRight } from 'lucide-react'; +import { textInputClass } from './bits'; +import { Icon } from './Icon'; + +/** A labelled group of controls inside a settings surface (the Settings panel + * or the bell's nudge-settings view) — one definition so both render the + * section chrome identically. */ +export function Group({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +/** One setting: a label (+ optional hint) over its control. */ +export function Field({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: ReactNode; +}) { + return ( +
+
+ {label} + {children} +
+ {hint && {hint}} +
+ ); +} + +/** A collapsible "how it works" note, so the same disclosure look and feel + * shows up in the Settings panel and the bell's nudge-settings view. Closed + * by default: it's there when you go looking, not in the way when you aren't. */ +export function Explainer({ summary, children }: { summary: string; children: ReactNode }) { + return ( +
+ + + {summary} + +
{children}
+
+ ); +} + +/** A small bounded number stepper (days, seconds). */ +export function NumberField({ + value, + min, + max, + suffix, + onChange, +}: { + value: number; + min: number; + max: number; + suffix: string; + onChange: (next: number) => void; +}) { + // held as a string so the field can go transiently empty while editing — + // Number('') is 0, which would otherwise clamp-and-commit on the first + // backspace and block typing a fresh multi-digit value + const [raw, setRaw] = useState(String(value)); + useEffect(() => setRaw(String(value)), [value]); + return ( + + { + const next = e.target.value; + setRaw(next); + if (next === '') return; + const n = Number(next); + if (Number.isFinite(n)) onChange(Math.min(max, Math.max(min, Math.round(n)))); + }} + onBlur={() => setRaw(String(value))} + className={`w-16 px-2 text-right tabular-nums ${textInputClass}`} + /> + {suffix} + + ); +} diff --git a/frontend-v2/src/components/StatePopover.tsx b/frontend-v2/src/components/StatePopover.tsx new file mode 100644 index 00000000..e5ee2477 --- /dev/null +++ b/frontend-v2/src/components/StatePopover.tsx @@ -0,0 +1,409 @@ +import type { ReactNode } from 'react'; +import { headStatuses, type DerivedPull } from '../../../shared/model/status'; +import type { Claim, RowNote } from '../model/actions'; +import { rowNote } from '../model/actions'; +import type { PullData } from '../../../shared/types'; +import { ago, closedEpoch, epoch, githubUrl, issueUrl, signatureUrl } from '../../../shared/format'; +import { ClosedBadge, STATUS_LABEL } from './bits'; +import { Avatar } from './identity'; +import { Popover } from './Popover'; + +interface FeedbackSource { + key: string; + login: string; + /** "blocked" / "changes requested" / "commented" */ + stateWord: string; + atEpoch: number; + body?: string; + url: string; +} + +function reviewStateWord(state: string): string { + return state === 'CHANGES_REQUESTED' ? 'changes requested' : 'commented'; +} + +/** CHANGES_REQUESTED sorts ahead of COMMENTED/DISMISSED — the harsher verdict + * is the more actionable one to see first. */ +function reviewRank(state: string): number { + return state === 'CHANGES_REQUESTED' ? 0 : 1; +} + +/** + * Every feedback source behind a pull's context line, in the order they + * should read: active dev/deploy blocks first (the reason the pull can't + * move at all), then unstamped reviewers (changes-requested before mere + * comments). Empty when the pull has nothing feedback-shaped to show. + */ +function feedbackSources(p: DerivedPull): FeedbackSource[] { + const d = p.data; + + const blockSources: FeedbackSource[] = [...d.status.dev_block, ...d.status.deploy_block] + .filter(s => s.data.active) + .map(s => ({ + key: `block-${s.data.comment_id}`, + login: s.data.user.login, + stateWord: s.data.type === 'dev_block' ? 'dev blocked' : 'deploy blocked', + atEpoch: epoch(s.data.created_at), + url: signatureUrl(s), + })); + + const reviewerSources: FeedbackSource[] = [...(d.status.unstamped_reviewers ?? [])] + .sort((a, b) => reviewRank(a.state) - reviewRank(b.state)) + .map((r, i) => ({ + key: `review-${r.login}-${i}`, + login: r.login, + stateWord: reviewStateWord(r.state), + atEpoch: r.date, + body: r.body, + url: + r.review_id != null + ? `${githubUrl(d.repo, d.number)}#pullrequestreview-${r.review_id}` + : githubUrl(d.repo, d.number), + })); + + return [...blockSources, ...reviewerSources]; +} + +/** The one-sentence gloss for a doOnly note (action set, context null) — + * rowNote's context carries the explanation for every other case, but these + * four never set one, so the popover restates the action in plain words + * instead of leaving Section 1 blank. */ +const ACTION_EXPLANATION: Record = { + Undraft: 'your draft; mark it ready on GitHub when you want review.', + 'Merge it': 'fully signed off and green, go ahead and merge.', + 'Finish QA': "you're already testing it, finish up and stamp.", + 'Re-QA': 'a push invalidated your QA stamp.', +}; + +function stateExplanation(pull: DerivedPull, note: RowNote): string { + if (note.context) return note.context; + if (note.action && ACTION_EXPLANATION[note.action]) return ACTION_EXPLANATION[note.action]; + return `${STATUS_LABEL[pull.status].toLowerCase()}.`; +} + +/** Section 1: what this pull's badge means, restated in a sentence, plus the + * viewer's own move when they have one. Claim/turn must flow into rowNote + * here exactly as they do on the row itself — otherwise the popover asserts + * "your next step: Review it" on a pull someone else has already claimed, flatly + * contradicting the row's own (rightly silent) do-pill. */ +function StateSection({ + pull, + me, + claim, + turn, +}: { + pull: DerivedPull; + me: string; + claim?: Claim | null; + turn?: string | null; +}) { + const note = rowNote(pull, me, { claim, turn }); + return ( +
+

{STATUS_LABEL[pull.status]}

+

{stateExplanation(pull, note)}

+ {note.action && ( +

your next step: {note.action}

+ )} +
+ ); +} + +/** Section 2: the sign-off/CI facts every row's rail hints at, spelled out — + * plus, when review coordination applies, who's claimed it or whose turn the + * rotation names (see model/rotation.ts, model/actions.ts's withCoordination). */ +function FactsSection({ + pull, + claim, + turn, + poolSize, +}: { + pull: DerivedPull; + claim?: Claim | null; + turn?: string | null; + poolSize?: number; +}) { + const d = pull.data; + const crReq = d.status.cr_req; + const qaReq = d.status.qa_req; + const ciWord = + pull.ci === 'failing' + ? 'failing' + : pull.ci === 'pending' + ? 'running' + : pull.ci === 'success' + ? 'passed' + : 'no checks required'; + // a failing check's name links straight to its run log (the status's + // target_url) — "which check, and show me" without a GitHub detour + const redLogs = new Map( + headStatuses(d) + .filter(s => s.data.state === 'failure' || s.data.state === 'error') + .map(s => [s.data.context, s.data.target_url]) + ); + const srcLink = (href: string, word: string) => ( + + {word} + + ); + return ( +
+ {/* no "· updated X ago" here: it duplicated the "last commit X ago" + the state line above already shows (same event, same timestamp) */} +

+ opened {ago(epoch(d.created_at))} ago by{' '} + {d.user.login} +

+ {/* the one readable home for the branch name — the row's copy action + only tucks it in a tooltip, and "which branch is this?" shouldn't + need a trip to GitHub */} +

+ branch {d.head.ref} + into {d.base.ref} +

+ {/* the conflict named and actionable in place: which branch it + fights, and the door to GitHub's conflict editor */} + {pull.conflict && ( +

+ conflicts with {d.base.ref} ·{' '} + {srcLink(`${githubUrl(d.repo, d.number)}/conflicts`, 'resolve on GitHub →')} +

+ )} + {d.closes != null &&

closes {srcLink(issueUrl(d.repo, d.closes), `#${d.closes}`)}

} + {d.connects != null && ( +

connects {srcLink(issueUrl(d.repo, d.connects), `#${d.connects}`)}

+ )} +

+ CR {pull.crHave} of {crReq} + {pull.crBy.length > 0 && <> · {pull.crBy.join(', ')}} + {pull.recrBy.length > 0 && ( + · stale: {pull.recrBy.join(', ')} + )} +

+

+ QA {pull.qaHave} of {qaReq} + {pull.qaBy.length > 0 && <> · {pull.qaBy.join(', ')}} + {pull.qaingLogin && <> · {pull.qaingLogin} testing} + {pull.reqaBy.length > 0 && ( + · stale: {pull.reqaBy.join(', ')} + )} +

+

+ CI: {ciWord} + {pull.ci === 'failing' && + pull.ciFailing.map(ctx => ( + + {' · '} + {srcLink(redLogs.get(ctx) || `${githubUrl(d.repo, d.number)}/checks`, ctx)} + + ))} +

+ {(d.status.comment_count ?? 0) > 0 && ( +

+ {d.status.comment_count} comment{d.status.comment_count === 1 ? '' : 's'} + {d.status.last_comment_at && <> · last {ago(epoch(d.status.last_comment_at))} ago} +

+ )} + {(d.assignees?.length ?? 0) > 0 &&

assigned to {d.assignees!.join(', ')}

} + {d.milestone.title && ( +

+ milestone {d.milestone.title} + {d.milestone.due_on && ( + · due {d.milestone.due_on.slice(0, 10)} + )} +

+ )} + {/* a claim always trumps the rotation guess — same precedence as + model/actions.ts's withCoordination */} + {claim ? ( +

+ claimed by {claim.login} + {claim.at != null && <> · {ago(claim.at)} ago} +

+ ) : ( + turn && ( +

+ rotation: {turn}’s turn · pool of{' '} + {poolSize ?? 0} +

+ ) + )} +
+ ); +} + +/** Section 3: the existing feedback excerpts, unchanged — just relocated + * into the universal popover instead of their own mini one. */ +function FeedbackSection({ pull }: { pull: DerivedPull }) { + const sources = feedbackSources(pull); + if (!sources.length) return null; + return ( +
+ {sources.map(s => ( +
+ + + {s.login} + {s.stateWord} + + {ago(s.atEpoch)} ago + + + {s.body &&

{s.body}

} + + View on GitHub → + +
+ ))} +
+ ); +} + +function StatePopoverBody({ + pull, + me, + claim, + turn, + poolSize, + whyHere, +}: { + pull: DerivedPull; + me: string; + claim?: Claim | null; + turn?: string | null; + poolSize?: number; + whyHere?: string | null; +}) { + return ( + <> + + + + {/* ranked lanes explain their pick per-card here — behind the same + door as everything else, never inline on the row */} + {whyHere && ( +

+ why it’s up next: + {whyHere} +

+ )} + + View PR on GitHub → + + + ); +} + +// pd-raise: every trigger here sits inside a row whose title link stretches +// an invisible click-through layer over the whole card (see styles.css) — +// without it, none of these would be clickable at all. +const TRIGGER_HINT = + 'pd-raise border-0 bg-transparent p-0 hover:underline hover:decoration-dotted hover:underline-offset-2'; + +/** + * The reusable door into the full-state popover: wrap any inline trigger + * content (the status badge, a do-pill, the repo#number ref) and it opens the + * same hover-pin panel — status restated, the sign-off/CI facts the rail only + * hints at, and (when present) the feedback behind it. Extracted so that when + * the status badge is suppressed as redundant with its container (see Row.tsx), + * the drill-down entry point can move onto whatever the card DOES still show, + * instead of vanishing with the badge. + */ +export function StatePopover({ + pull, + me, + claim, + turn, + poolSize, + whyHere, + title = 'see the full state', + children, +}: { + pull: DerivedPull; + me: string; + claim?: Claim | null; + turn?: string | null; + poolSize?: number; + /** a ranked lane's one-line reason this pull sits where it does */ + whyHere?: string | null; + title?: string; + children: ReactNode; +}) { + return ( + ( + + )} + > + + + ); +} + +/** + * The closed-row receipt: same trigger pattern, a much shorter panel — a + * merged/closed pull has no state to restate, no facts left to owe anyone, + * just when it landed and a link to the record. + */ +export function ClosedBadgeTrigger({ pull }: { pull: PullData }) { + const merged = !!pull.merged_at; + const closedAt = closedEpoch(pull); + return ( + ( + + )} + > +

+ {merged ? 'merged' : 'closed'} {ago(closedAt)} ago by{' '} + {pull.user.login} +

+ + View PR on GitHub → + +
+ ); +} diff --git a/frontend-v2/src/components/TeamPicker.tsx b/frontend-v2/src/components/TeamPicker.tsx new file mode 100644 index 00000000..8d1ba61d --- /dev/null +++ b/frontend-v2/src/components/TeamPicker.tsx @@ -0,0 +1,246 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Trash2, X } from 'lucide-react'; +import { displayName, useNames } from '../model/names'; +import { isBotLogin } from '../../../shared/model/visibility'; +import { getSettings, removeTeam, renameTeam, toggleTeammate, useSettings } from '../settings'; +import { usePulldasher } from '../store'; +import { textInputClass } from './bits'; +import { CheckboxField, FilterSearch, hoverRowClass } from './filters/shared'; +import { Avatar } from './identity'; +import { Icon } from './Icon'; +import { useArmedConfirm } from './useArmedConfirm'; +import { commitKeyHandler } from './useCommitOnEnter'; + +const SUGGESTION_CAP = 12; +const EMPTY_BOTS: ReadonlySet = new Set(); + +/** One row: checkbox + avatar + name (+ optional trailing count), the same + * shape the Filters people tab uses so a teammate row reads identically + * wherever it appears. */ +function CandidateRow({ + login, + name, + count, + checked, + self, + onToggle, +}: { + login: string; + /** human display name, when known (model/names.ts) */ + name?: string | null; + count?: number; + checked: boolean; + self?: boolean; + onToggle: () => void; +}) { + return ( + } + title={login} + className={`gap-2 text-[13px] ${hoverRowClass}`} + textClassName="min-w-0 flex-1 truncate" + count={count == null ? undefined : count || ''} + > + {name ?? login} + {self && (you)} + + ); +} + +/** Every author and signer on the board — the login universe a teammate can + * be picked from — with an open-PR count for sorting. Bots are excluded (a + * dependency bot never reviews anything); you are not — some people want their + * own PRs to ride along in the Team view, so you can tick yourself. */ +function useCandidates(extraBots: ReadonlySet) { + const { pulls } = usePulldasher(); + return useMemo(() => { + const counts = new Map(); + const known = new Set(); + for (const p of pulls) { + const login = p.data.user.login; + known.add(login); + counts.set(login, (counts.get(login) ?? 0) + 1); + for (const sig of [...p.data.status.allCR, ...p.data.status.allQA]) { + known.add(sig.data.user.login); + } + } + return [...known] + .filter(login => !isBotLogin(login, extraBots)) + .sort((a, b) => (counts.get(b) ?? 0) - (counts.get(a) ?? 0) || a.localeCompare(b)) + .map(login => ({ login, count: counts.get(login) ?? 0 })); + }, [pulls, extraBots]); +} + +/** + * One roster's member picker: current members first (uncheck to remove), + * then a searchable, capped list of everyone else on the board. Typing a + * login with no match on the board still lets you add it — a teammate can + * have nothing open right now. Creating, choosing, and removing rosters + * happens on the Team lens itself; this panel only ever edits the one team + * it was opened for. Standalone so the Team view's empty state can embed it + * without any drawer chrome around it. + */ +export function TeamPicker({ + teamName, + extraBots = EMPTY_BOTS, +}: { + /** the roster this picker edits (created on first add if absent) */ + teamName: string; + extraBots?: ReadonlySet; +}) { + const { teams } = useSettings(); + const { me } = usePulldasher(); + const candidates = useCandidates(extraBots); + const [query, setQuery] = useState(''); + // the roster's name, editable in place — draft locally, commit on + // Enter/blur; renameTeam no-ops on blank or collision so the field just + // stays put on a bad name + const [nameDraft, setNameDraft] = useState(teamName); + useEffect(() => setNameDraft(teamName), [teamName]); + const commitRename = () => { + renameTeam(teamName, nameDraft); + // renameTeam no-ops on a blank or colliding name, leaving `teamName` + // unchanged — when that happens the draft must not keep showing the + // rejected text indefinitely + if (getSettings().teams.some(t => t.name === teamName)) setNameDraft(teamName); + }; + const activeName = teamName; + const exists = teams.some(t => t.name === teamName); + const activeMembers = teams.find(t => t.name === teamName)?.members ?? []; + // deleting a roster is arm-then-confirm, same as removing a saved filter: + // one misclick must not erase a hand-built list of people + const { armed, run } = useArmedConfirm(); + + // search by handle OR human name — "metz" should find djmetzle + const namesMap = useNames(); + const nameOf = (login: string) => displayName(namesMap, login); + + // logins are case-insensitive (one GitHub account), so membership must be + // too — otherwise "JDoe" and "jdoe" pass as two different people + const members = new Set(activeMembers.map(l => l.toLowerCase())); + const trimmed = query.trim(); + const needle = trimmed.toLowerCase(); + const available = candidates.filter(c => !members.has(c.login.toLowerCase())); + const filtered = needle + ? available.filter( + c => + c.login.toLowerCase().includes(needle) || + (nameOf(c.login) ?? '').toLowerCase().includes(needle) + ) + : available.slice(0, SUGGESTION_CAP); + + const exactMatch = + !!trimmed && (members.has(needle) || candidates.some(c => c.login.toLowerCase() === needle)); + + return ( +
+ setNameDraft(e.target.value)} + onBlur={commitRename} + onKeyDown={commitKeyHandler({ + onCommit: commitRename, + onCancel: () => setNameDraft(teamName), + })} + aria-label="team name: edit to rename" + title="the team’s name: edit it here to rename" + className={`mb-1.5 w-full px-2 font-semibold ${textInputClass}`} + /> +

+ Anyone whose work you review belongs on a roster: your review circle, not the org chart. + Everyone on any roster leads your review queues. +

+ {/* current members as removable face chips — horizontal and + recognizable, not a column of checkbox rows. The ✕ removes; adding + more happens in the search below. */} + {activeMembers.length > 0 && ( +
+ {activeMembers.map(login => ( + + + + {nameOf(login) ?? login} + + + + ))} +
+ )} + + + {filtered.map(c => ( + toggleTeammate(c.login, true, activeName)} + /> + ))} + {filtered.length === 0 && !trimmed && ( +
No one else on the board yet.
+ )} + + {trimmed && !exactMatch && ( + + )} + + {/* the roster's one management panel is also where it can end — + not buried behind "empty it first, then a button appears" */} + {exists && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend-v2/src/components/WordGroups.tsx b/frontend-v2/src/components/WordGroups.tsx new file mode 100644 index 00000000..dfde7c18 --- /dev/null +++ b/frontend-v2/src/components/WordGroups.tsx @@ -0,0 +1,154 @@ +import { DO_WORD_RANK, rowWord, WAIT_WORD_RANK, type RowWord } from '../model/actions'; +import { groupIntoTree, type StackedPull } from '../model/stack'; +import type { DerivedPull } from '../../../shared/model/status'; +import { pullKey } from '../../../shared/format'; +import { claimFor } from '../model/reviewers'; +import { Fold, Truncated } from './Lane'; +import { Row, type RowOptions } from './Row'; + +export { eyebrowText } from './Lane'; + +/** + * One plain sentence per group word, shown on hovering the header — the + * eyebrow explains itself in place, so the legend stays documentation and is + * never required reading. Viewer-relative on purpose: do-words describe your + * action, wait-words describe what the pull is waiting on. A word missing + * here renders as a plain header (the safe fallback for future vocabulary). + */ +const WORD_GLOSS: Record = { + // do-words: the next step is yours + 'Re-stamp': + 'You approved this PR, then new commits landed and undid your approval. Check the changes and approve again.', + 'Re-QA': 'You tested this PR, then new commits landed. Test it again.', + 'Finish QA': 'You started testing these. Finish and stamp.', + 'Finish CR': 'You started reviewing; your stamp isn’t in yet.', + 'Re-review': 'You asked for changes and they pushed. Take another look.', + Merge: 'Fully signed off and green. Your merge button.', + 'Fix CI': 'Your PR with a failing build. Nobody can review it until it’s green.', + Respond: 'A reviewer left feedback that waits on your answer.', + Unblock: 'A block of yours is what holds it. Lift it when you’re ready.', + Rebase: 'Your PR conflicts with its base branch.', + 'Nudge CR': 'Your PR, and nobody has reviewed it yet. Worth a ping.', + 'Find a QA-er': + 'Your PR doesn’t need more code review, it needs someone to test it. Line a tester up.', + Review: 'Open PRs you could code review.', + QA: 'Open PRs you could test.', + Undraft: 'Your draft. Mark it ready on GitHub when you want review.', + // wait-words: why the pull sits + 'waiting on re-CR': + 'A reviewer approved it, then new commits landed. Waiting on them to approve again.', + 'waiting on CR': 'Waiting for someone to code review it.', + 'with author': 'Changes were requested; the next push is the author’s.', + 'waiting on re-QA': 'Someone tested it, then new commits landed. Waiting on them to test again.', + 'waiting on QA': 'Waiting for someone to test it.', + 'in QA': + 'Someone is testing it right now: they added the QAing label on GitHub. Add it yourself to claim a QA.', + claimed: 'Someone flagged they’re reading it.', + stamped: 'Your stamp is in; waiting on the rest of the sign-offs.', + 'CI running': 'CI checks are still running on the latest push.', + 'CI failing': + 'A required CI check is failing; the author fixes that first. Stale stamps wait too: nobody is asked to re-stamp until the build is green.', + blocked: + 'Someone left a dev block; the author owes changes first. Stale stamps wait until the block lifts.', + 'deploy hold': 'Done, but deliberately not shipped yet.', + conflicts: 'Conflicts with its base branch; the author rebases.', + stacked: 'Based on another open PR; it merges with its parent.', + 'on hold': 'Blocked on something outside this repo.', + parked: + 'Labeled Cryogenic Storage: shelved on purpose. Nothing is asked of anyone while it’s parked.', + ready: 'Fully signed off and green; anyone can merge it, usually the author does.', + draft: 'Not up for review yet.', + waiting: 'Waiting on something GitHub’s data doesn’t name. Open the PR to see.', +}; + +export interface WordGroup { + word: string; + kind: RowWord['kind']; + nodes: StackedPull[]; +} + +/** + * Buckets a stack-aware tree (model/stack.ts's groupIntoTree) by each root + * pull's rowWord — the same word that would have been the card's badge. + * A stacked child always follows its root into the root's bucket, so a + * stack never splits across two headers. Buckets are keyed by (kind, word) + * so every "Re-stamp" card lands in one group regardless of where it fell in + * the input order; the RETURN order is what's ranked — do-groups first (most + * urgent DO_WORD_RANK first), then wait-groups (WAIT_WORD_RANK), unknown + * words last, ties kept in first-seen order (Array#sort is stable). + */ +function groupNodesByWord(tree: StackedPull[], me: string): WordGroup[] { + const buckets = new Map(); + let currentKey: string | null = null; + for (const node of tree) { + if (node.depth === 0) { + const rw = rowWord(node.pull, me, { claim: claimFor(node.pull.data) }); + currentKey = `${rw.kind}:${rw.word}`; + if (!buckets.has(currentKey)) { + buckets.set(currentKey, { word: rw.word, kind: rw.kind, nodes: [] }); + } + } + // depth > 0 with no depth-0 seen yet can't happen — groupIntoTree always + // places a root before its children — but the guard keeps this total. + if (currentKey) buckets.get(currentKey)!.nodes.push(node); + } + const rankOf = (g: WordGroup): number => { + const rank = g.kind === 'do' ? DO_WORD_RANK : WAIT_WORD_RANK; + const idx = rank.indexOf(g.word); + return idx === -1 ? Number.POSITIVE_INFINITY : idx; + }; + const kindOrder = (k: RowWord['kind']): number => (k === 'do' ? 0 : 1); + return [...buckets.values()].sort( + (a, b) => kindOrder(a.kind) - kindOrder(b.kind) || rankOf(a) - rankOf(b) + ); +} + +/** + * The full word-grouped renderer: stack the pulls, bucket them by rowWord, + * and render each bucket as its own Fold — the board's one subsection band, + * so every group can be collapsed and the choice is remembered. `cap` rations + * rows per group ("+N more" inside the fold); primary lanes greet you open + * (`foldDefaultOpen`), ledger uses pass false so the stack of bands reads as + * a quiet table of contents. + */ +export function WordGroupRows({ + pulls, + opts, + id, + cap, + foldDefaultOpen = true, +}: { + pulls: DerivedPull[]; + opts: RowOptions; + id?: string; + cap: number; + foldDefaultOpen?: boolean; +}) { + const tree = groupIntoTree(pulls); + const groups = groupNodesByWord(tree, opts.me); + if (!groups.length) return null; + return ( + <> + {groups.map(g => { + const gid = id ? `${id}:${g.kind}:${g.word}` : undefined; + return ( + + + {g.nodes.map(({ pull: p, depth }) => ( + + ))} + + + ); + })} + + ); +} diff --git a/frontend-v2/src/components/Wordmark.tsx b/frontend-v2/src/components/Wordmark.tsx new file mode 100644 index 00000000..115f9e23 --- /dev/null +++ b/frontend-v2/src/components/Wordmark.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState, type CSSProperties } from 'react'; + +/** + * The "pulldasher" wording next to the mark, one span per letter so the + * word can be DEALT: letters slide out from behind the deer left-to-right + * on show and tuck back toward it on hide (styles.css .wm-*) — the same + * gesture as the board dealing you a review. Shown only when the header + * gutter can afford it (the 2xl breakpoint the neighboring header text + * uses), but mounted always: the h1 is absolutely positioned, so neither + * state touches layout. + * + * Mounts straight into its final state on purpose: page load gets NO + * animation (a board you open ten times a day shouldn't perform an + * entrance) — the letters only deal when a resize carries the wordmark + * across the breakpoint, in or out of view. + * + * aria-hidden: the Logo beside it already carries the accessible name + * "Pulldasher"; per-letter spans would only spell noise at a screen reader. + */ +export function Wordmark() { + const [shown, setShown] = useState(() => matchMedia('(min-width: 1536px)').matches); + useEffect(() => { + const mq = matchMedia('(min-width: 1536px)'); + const follow = () => setShown(mq.matches); + mq.addEventListener('change', follow); + return () => mq.removeEventListener('change', follow); + }, []); + return ( + + {[...'pulldasher'].map((ch, i) => ( + = 4 ? 'text-brand' : ''}`} + style={{ '--i': i } as CSSProperties} + > + {ch} + + ))} + + ); +} diff --git a/frontend-v2/src/components/age.tsx b/frontend-v2/src/components/age.tsx new file mode 100644 index 00000000..c3f8093e --- /dev/null +++ b/frontend-v2/src/components/age.tsx @@ -0,0 +1,227 @@ +import { ROT_DAYS, STARVE_DAYS } from '../../../shared/model/status'; +import { ago } from '../../../shared/format'; +import { railTriggerClass } from './bits'; +import { Popover } from './Popover'; + +/** + * The age popover's body: "opened X ago", "last activity Y ago", and (only + * when the row's age line is actually drawn) the line's own relative-to- + * the-oldest explanation. Shared by AgeStamp (the numeral, every row) and + * AgeBaseline (the hoverable band, only aging rows) so the two doors into + * the same fact can never drift apart — one copy, two triggers. + */ +function AgePopoverBody({ + createdAt, + updatedAt, + explainLine, +}: { + /** epoch secs the pull opened */ + createdAt: number; + /** epoch secs of the last activity */ + updatedAt: number; + /** the caller decides: the numeral shows on every row and only adds this + * once its own age crosses warnDays, the band only ever renders once + * that's already true, so it always passes it. */ + explainLine: boolean; +}) { + return ( + <> + + opened {ago(createdAt)} ago + + + last activity {ago(updatedAt)} ago + + {explainLine && ( + + the grey line under this row is its age, relative to the board’s oldest open pull + + )} + + ); +} + +/** + * Age as the row's own baseline: a 1px hairline along the bottom edge — a + * tinted stretch of the divider the row already has, not a drawn bar. + * RELATIVE, not thresholded: the board's longest-open pull sets the full + * track and every row is a fraction of it, so the line answers "how long + * has this waited, relative to what waiting looks like here." The tint + * deepens with that same fraction, ultra-light ink for the merely-aging + * through full ink-3 for the oldest — age is a quiet fact in grey, never + * a colored alarm (an amber version shipped for an hour and was the + * loudest thing on the board; urgency belongs to the queue's ranking and + * the numeral's weight). Silent below the aging threshold — a healthy + * young row draws nothing. + * + * The line is also a door now, not pure geometry: a 10px invisible hit strip + * (`.pd-age-track`/`.pd-age-hit` in styles.css), sized to the SAME fraction + * as the visible line so a 1px target doesn't need pixel-hunting, opens the + * same age popover the numeral shows on hover or focus and grows the line to + * an 8px band while it's open. At rest it's pixel-identical to the old + * static hairline. + */ +export function AgeBaseline({ + ageDays, + createdAt, + updatedAt, + warnDays = STARVE_DAYS, + maxAgeDays = 1, + quiet, +}: { + ageDays: number; + /** epoch secs the pull opened — feeds the shared age popover body */ + createdAt: number; + /** epoch secs of the last activity — feeds the shared age popover body */ + updatedAt: number; + warnDays?: number; + /** the board's longest-open pull — the 100% mark of the track */ + maxAgeDays?: number; + /** drafts and holds age on purpose: draw nothing */ + quiet?: boolean; +}) { + if (quiet || ageDays < warnDays) return null; + // A square-root curve, not a linear fraction: age doesn't feel linear + // (2→5 days reads as a big jump, 60→63 as almost none — Stevens' power + // law), and one very old PR setting the 100% mark would otherwise squash + // the whole actionable range into invisible slivers. √ expands the + // young/mid range (more visible growth per day early) and compresses the + // ancient tail (old is old), so the bar discriminates where the daily + // decisions are. Still relative to the board's oldest open pull; tune the + // exponent to steepen (→1 linear) or flatten (→0) the curve. + const t = Math.sqrt(Math.min(ageDays / Math.max(maxAgeDays, 1), 1)); + // tint rides the same fraction as length: ~30% border-grey at the + // gate, the full border color on the board's oldest (owner call: the + // ink ramp read too dark against the row divider it extends) + const inkPct = Math.round(30 + 70 * t); + return ( + // the percentage width lives on this outer track (not the line itself, + // see styles.css): the line and its taller hit area both fill 100% of + // it, so hovering anywhere along the row's actual age fraction — never + // past it — opens the door. + + ( + + )} + > + + + + ); +} + +/** + * The age slot: hours under a day, then days, with both clocks in the + * popover. Hours matter here: in three months of real history, 62% of + * pulls merged same-day, so "0d" was a dead signal for most of the live + * board. It rides at the rail's far right, capping the row — and it + * WHISPERS: its resting color is the age line's own border tint, rising to + * ink on row hover (see styles.css .pd-age-num). Age's salience is carried + * once, by the baseline built for continuous gradation; the numeral is the + * label you consult, not a second alarm. Font weight still steps at the + * warn/rot thresholds so the hover read carries the urgency. + * + * `display` picks the clock the numeral shows (opened vs last update); the + * urgency weight always follows the OPENED clock — how long a pull has been + * open is the truth the board ranks by, whichever number the user prefers + * to read. + */ +export function AgeStamp({ + ageDays, + createdAt, + updatedAt, + quiet, + warnDays = STARVE_DAYS, + rotDays = ROT_DAYS, + inline, + display = 'opened', +}: { + ageDays: number; + /** epoch secs the pull opened */ + createdAt: number; + /** epoch secs of the last activity */ + updatedAt: number; + /** drafts and holds age on purpose: no urgency weight */ + quiet?: boolean; + /** heavier type at/after this many days (user setting; defaults to the model's) */ + warnDays?: number; + /** heaviest type at/after this many days */ + rotDays?: number; + /** true when it's embedded in a flowing meta line rather than the rail's + * fixed-width column — drops the w-7/text-right slot in favor of plain + * inline text. */ + inline?: boolean; + /** which clock the numeral shows (settings.ageDisplay) */ + display?: 'opened' | 'updated'; +}) { + const heft = quiet + ? '' + : ageDays >= rotDays + ? 'font-semibold' + : ageDays >= warnDays + ? 'font-medium' + : ''; + const shownEpoch = display === 'updated' ? updatedAt : createdAt; + const shownDays = + display === 'updated' + ? Math.max(0, Math.floor((Date.now() / 1000 - updatedAt) / 86400)) + : ageDays; + const text = shownDays === 0 ? ago(shownEpoch) : `${shownDays}d`; + // a popover, not a title: the second clock (last activity) exists nowhere + // else on the row, and a native tooltip is mouse-only — this way touch + // taps it and keyboard reads it from the aria-label + return ( + ( + + )} + > + = warnDays} + /> + + ); +} diff --git a/frontend-v2/src/components/bits.tsx b/frontend-v2/src/components/bits.tsx new file mode 100644 index 00000000..d64c7d30 --- /dev/null +++ b/frontend-v2/src/components/bits.tsx @@ -0,0 +1,422 @@ +import { forwardRef, useEffect, useState } from 'react'; +import type { ButtonHTMLAttributes, KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react'; +import type { Status, Weight } from '../../../shared/model/status'; +import { githubUrl, shortRepo } from '../../../shared/format'; +import { getSettings } from '../settings'; +import { Icon, type LucideComponent } from './Icon'; +import { Popover } from './Popover'; + +/** + * The board's small generic scraps: status label/dot vocabulary, weight + * words, corner badges, buttons, diff sizes, the title link + repo ref, and + * the plain form controls (Segmented, Switch, EmptyState). The three domain + * systems that used to share this file — author identity (the silhouette + * and the star), the age ruler, and the sign-off/CI pip family — moved to + * identity.tsx, age.tsx, and pips.tsx. + */ + +export const STATUS_LABEL: Record = { + ready: 'Ready to merge', + // only at the ready gate: fully signed off, CI is the one thing left. + // 'CI running' would lie — a needs-CR pull can have CI running too — so + // the badge names the WAIT, not the machinery. + ci_pending: 'Waiting on CI', + needs_recr: 'Needs re-CR', + needs_qa: 'Needs QA', + needs_cr: 'Needs CR', + // the old "Blocked" wore one badge for three opposite situations; each + // now says whose move it is + dev_block: 'Dev blocked', + deploy_block: 'Deploy block', + unmergeable: 'Can’t merge', + ci_red: 'CI failing', + draft: 'Draft', +}; + +/** + * The house text-input recipe: the plain-bordered field shape every board + * name/search/number field shares. Callers append their own width, margin, + * and horizontal padding (a search box's icon inset differs from a plain + * name field's). + */ +export const textInputClass = 'h-8 rounded-lg border border-line bg-surface text-[13px]'; + +/** + * Grows a rail hairline into a real tap target without shifting row height: + * a negative vertical margin matched by the same vertical padding, borderless + * and transparent so the mark inside is unaffected, plus the hover wash every + * rail trigger reveals on and `hit`'s widened click/tap target. Callers + * append their own sizing (width, gap, flex, horizontal padding) and any + * state-dependent classes. + */ +export const railTriggerClass = + 'hit -my-2 rounded border-0 bg-transparent py-2 hover:bg-secondary/60'; + +export const STATUS_DOT: Record = { + ready: 'var(--ok)', + ci_pending: 'var(--slate)', + // a lapsed stamp is owed work, amber by doctrine — reaches the screen via + // Stats' status bar + needs_recr: 'var(--warn)', + needs_qa: 'var(--violet)', + needs_cr: 'var(--ink-3)', + dev_block: 'var(--warn)', + deploy_block: 'var(--ink-3)', + unmergeable: 'var(--ink-3)', + ci_red: 'var(--bad)', + draft: 'var(--border)', +}; + +/** + * The quiet outlined button the settings surfaces share — one definition so a + * radius or hover tweak lands everywhere. sm = inline row actions (hide, + * show), md = standalone panel actions. tone='brand' for the affirmative + * variant ("Show for me"). + */ +/** + * The corner count/state badge the filter triggers and lens tabs share: + * absolutely positioned, so appearing or changing NEVER moves its host — + * data landing mid-load can't reflow the bar. 1–3 characters (a count, a + * lone weight's letters, a −count for an exclusion). 'brand' = something of + * yours or a narrowing you chose; 'quiet' = standing info (the hidden + * ledger's count). aria-hidden: the host's aria-label carries the words. + */ +export function CornerBadge({ text, tone = 'brand' }: { text: string; tone?: 'brand' | 'quiet' }) { + return ( + + {text} + + ); +} + +export function QuietButton({ + size = 'sm', + tone = 'default', + ...props +}: ButtonHTMLAttributes & { + size?: 'sm' | 'md'; + tone?: 'default' | 'brand'; +}) { + const shape = + size === 'sm' + ? 'rounded-md px-2 py-0.5 text-xs' + : 'inline-flex h-8 items-center rounded-lg px-3 text-[13px]'; + const text = tone === 'brand' ? 'text-brand' : 'text-ink-2'; + return ( + + ); +}); + +/** + * Merged/closed state as a pill — the one badge left on the board; open pulls + * are badge-less and carry state via section position + the state popover. + */ +export function ClosedBadge({ merged, inline }: { merged: boolean; inline?: boolean }) { + return ( + + {merged ? 'Merged' : 'Closed'} + + ); +} + +export const WEIGHT_WORD: Record = { + XS: 'very light', + S: 'light', + M: 'medium', + L: 'heavy', + XL: 'very heavy', +}; + +/** + * The concrete diff size beside the abstract weight letter: +added −deleted, + * so the exact number is there when the letter chip isn't precise enough. + * Additions and deletions, colored green/red in the weight popover — the + * GitHub-familiar read the owner asked for. Contained to that one popover call + * site: the board's rows still reserve red for broken CI and green for a live + * sign-off, so this detail-panel count borrows the hues without teaching the + * eye to tune them out on a row. Hidden when the wire didn't send a size. + */ +export function DiffSize({ additions, deletions }: { additions: number; deletions: number }) { + return ( + + +{additions}{' '} + −{deletions} + + ); +} + +/** + * The full-title GitHub link every row variant renders — never truncated. + * With `stretch`, its click target covers the whole positioned row (see + * .pd-link): the entire card opens the PR, while raised children stay + * clickable. The visible title still underlines on hover so it reads as the + * link, and j/k / middle-click still land on this anchor. + */ +export function PullTitleLink({ + repo, + number, + title, + body, + stretch, +}: { + repo: string; + number: number; + title: string; + /** the PR description; when present, hovering the visible title previews + * it rendered — the "can I act on this?" read without leaving the board */ + body?: string; + /** cover the whole row as one click target */ + stretch?: boolean; +}) { + // The whole-card click honors your "open PRs in a new tab" setting so the + // hub stays put behind you (the default) or navigates in place if you'd + // rather. Read non-reactively: the row that renders this already subscribes + // to settings, so a toggle re-renders it and this picks up the new value. + const newTab = getSettings().openPrsNewTab; + // no door where there's nothing behind it: an empty description gets no + // popover, so the hover promise is always kept + const preview = body?.trim() ? body : null; + return ( + + {preview ? ( + // hoverTriggerOnly scopes the hover door to the visible words: the + // anchor's stretched hit layer covers the whole row, and a preview + // that opened from anywhere on the card would be a popover storm + ( + + {title} + + )} + > + + + ) : ( + title + )} + + ); +} + +/** The rendered PR description inside the title's hover preview. Markdown + * tooling (marked + DOMPurify) loads as its own chunk on the first hover — + * board startup never pays for it. */ +function DescriptionBody({ md }: { md: string }) { + const [html, setHtml] = useState(null); + useEffect(() => { + let live = true; + void import('../markdown').then(m => { + if (live) setHtml(m.renderMarkdown(md)); + }); + return () => { + live = false; + }; + }, [md]); + if (html == null) return
Loading…
; + // sanitized in renderMarkdown; the description is author-authored remote + // content and never lands in the DOM unsanitized + return
; +} + +/** + * The number is what you say out loud; the repo is ambient context. Split + * them typographically so the identifier steps forward and the repo whispers. + */ +export function RepoRef({ repo, number }: { repo: string; number: number }) { + return ( + + {shortRepo(repo)}{' '} + #{number} + + ); +} + +/** + * A pill segmented control — the house pattern for a small closed choice + * (default view, density, Mine/All drafts). One radiogroup, so it reads as + * "pick exactly one" to a screen reader instead of a row of toggle buttons. + */ +export function Segmented({ + value, + options, + onChange, + ariaLabel, +}: { + value: T; + options: [T, string][]; + onChange: (next: T) => void; + ariaLabel: string; +}) { + // role=radio sets the APG expectation: one Tab stop for the group, arrows + // move the selection. Without this the role announces a contract ("radio + // 1 of 3, use arrows") the control doesn't honor. + const hasSelection = options.some(([v]) => v === value); + const moveSelection = (e: ReactKeyboardEvent, from: number) => { + const delta = + e.key === 'ArrowRight' || e.key === 'ArrowDown' + ? 1 + : e.key === 'ArrowLeft' || e.key === 'ArrowUp' + ? -1 + : 0; + const next = + e.key === 'Home' + ? 0 + : e.key === 'End' + ? options.length - 1 + : delta + ? (from + delta + options.length) % options.length + : null; + if (next == null) return; + e.preventDefault(); + onChange(options[next][0]); + (e.currentTarget.parentElement?.children[next] as HTMLElement | undefined)?.focus(); + }; + return ( +
+ {options.map(([val, label], i) => ( + + ))} +
+ ); +} + +/** A compact on/off switch — a track with a sliding knob. Used where a full + * Segmented (On/Off) would be too heavy, e.g. a long list of per-item toggles. */ +export function Switch({ + checked, + onChange, + ariaLabel, + disabled, +}: { + checked: boolean; + onChange: (next: boolean) => void; + ariaLabel: string; + disabled?: boolean; +}) { + return ( + + ); +} + +export function EmptyState({ title, sub }: { title: string; sub: string }) { + return ( +
+ + + + + {title} + {sub} +
+ ); +} diff --git a/frontend-v2/src/components/filters/HiddenPanel.tsx b/frontend-v2/src/components/filters/HiddenPanel.tsx new file mode 100644 index 00000000..563f7e78 --- /dev/null +++ b/frontend-v2/src/components/filters/HiddenPanel.tsx @@ -0,0 +1,219 @@ +import { CRYO_KEY } from '../../../../shared/model/visibility'; +import { setSettings, useSettings } from '../../settings'; +import { QuietButton } from '../bits'; +import { Popover } from '../Popover'; +import { CheckboxField, FilterTrigger } from './shared'; + +/** Per-category sizes of everything the board hides by default — computed in + * app.tsx beside the visibility filter itself so the two can't drift. */ +interface HiddenCounts { + /** PRs labeled Cryogenic Storage */ + parked: number; + /** other people's drafts (yours always show) */ + drafts: number; + /** PRs in repos you hid */ + hiddenRepos: number; + /** PRs by people you hid */ + hiddenPeople: number; + /** bot PRs on the board (the pool "Ignore bot PRs" would hide) */ + bots: number; + /** PRs currently off the board, after session reveals */ + hiddenNow: number; +} + +/** + * The board's hidden-PR ledger: one quiet door at the end of the filter bar + * that always says how many open PRs you are NOT seeing, and opens into the + * why — parked, drafts, hidden repos and people — each with its count. The two + * categories with clean session toggles (parked, drafts) toggle right here; + * the durable ones say where their controls live instead of duplicating + * them. Replaces the parked-PRs checkbox that hid at the bottom of the Repos + * popover: what the board withholds is board-level information, not a repo + * setting, and a count you can always see is what makes hiding trustworthy. + */ +export function HiddenPanel({ + counts, + showAll, + setShowAll, + reveal, + toggleReveal, + draftsMode, + setDraftsMode, +}: { + counts: HiddenCounts; + showAll: boolean; + setShowAll: (next: boolean) => void; + reveal: string[]; + toggleReveal: (key: string) => void; + draftsMode: 'mine' | 'all'; + setDraftsMode: (m: 'mine' | 'all') => void; +}) { + const settings = useSettings(); + const parkedShown = settings.showCryo || reveal.includes(CRYO_KEY) || showAll; + const draftsShown = draftsMode === 'all' || showAll; + // session reveals in effect — the trigger flips to "showing hidden ×" so + // the undo is one click, mirroring how an active filter clears + const revealing = showAll || reveal.length > 0 || draftsMode !== settings.draftsMode; + const anythingHidden = + counts.parked + counts.drafts + counts.hiddenRepos + counts.hiddenPeople > 0; + // the bot toggle keeps the door reachable even when nothing else is hidden: + // it's the one durable "hide a whole category" control that lives here + if (!anythingHidden && !revealing && !counts.bots && !settings.hideBots) return null; + + const resetReveals = () => { + setShowAll(false); + for (const k of reveal) toggleReveal(k); + setDraftsMode(settings.draftsMode); + }; + + const toggleRow = ( + key: string, + label: string, + count: number, + checked: boolean, + disabled: boolean, + onToggle: () => void, + gloss: string, + makeDefault?: () => void + ) => ( +
+ + {label} + +

{gloss}

+ {makeDefault && ( +
+ + Make this my default + +
+ )} +
+ ); + + const infoRow = (key: string, label: string, count: number, hint: string) => ( +
+
+ {label} + {count} +
+

{hint}

+
+ ); + + return ( + + ( + + + ); +} diff --git a/frontend-v2/src/components/filters/PeopleFilter.tsx b/frontend-v2/src/components/filters/PeopleFilter.tsx new file mode 100644 index 00000000..d8914b06 --- /dev/null +++ b/frontend-v2/src/components/filters/PeopleFilter.tsx @@ -0,0 +1,222 @@ +import { useState } from 'react'; +import { ChevronRight } from 'lucide-react'; +import type { DerivedPull } from '../../../../shared/model/status'; +import { displayName, useNames } from '../../model/names'; +import { toggleScopeMember, type Scope } from '../../prefs'; +import { toggleHiddenPerson, useSettings } from '../../settings'; +import { usePulldasher } from '../../store'; +import { Icon } from '../Icon'; +import { Avatar } from '../identity'; +import { Popover } from '../Popover'; +import { + CheckboxField, + ClearRow, + EyeButton, + FilterRow, + FilterSearch, + FilterTrigger, + OnlyButton, +} from './shared'; + +/** + * The people filter: a searchable author list where scope (on my board) and + * hide (off my board, period) live side by side on each row — the same + * scope/hide-together layout RepoFilter uses for repos. Whole-team narrowing + * lives in the pinned saved searches, not here: every roster already derives + * one, so this panel stays a per-person surface. The "everyone except" lane + * has no button of its own (a worded control per row was what crushed the + * name column): ⇧-clicking a row's checkbox excludes, and the struck-through + * row plus the trigger's −N badge stand for the choice. + */ +export function PeopleFilter({ + pulls, + scope, + setScope, +}: { + pulls: DerivedPull[]; + scope: Scope; + setScope: (next: Scope) => void; +}) { + const { me } = usePulldasher(); + const settings = useSettings(); + // login -> human name (app.tsx prefetches everyone on the board), so the + // list can read and search by the person, not just the handle + const namesMap = useNames(); + const nameOf = (login: string) => displayName(namesMap, login); + const matchesPerson = (login: string, q: string) => + login.toLowerCase().includes(q) || (nameOf(login) ?? '').toLowerCase().includes(q); + const hiddenSet = new Set(settings.hiddenPeople); + const [peopleQuery, setPeopleQuery] = useState(''); + + const authorCounts = new Map(); + for (const p of pulls) + authorCounts.set(p.data.user.login, (authorCounts.get(p.data.user.login) ?? 0) + 1); + for (const name of scope.authors) if (!authorCounts.has(name)) authorCounts.set(name, 0); + for (const name of settings.hiddenPeople) if (!authorCounts.has(name)) authorCounts.set(name, 0); + const authors = [...authorCounts.entries()].sort( + (a, b) => b[1] - a[1] || a[0].localeCompare(b[0]) + ); + const shownAuthors = authors.filter(([login]) => !hiddenSet.has(login)); + const hiddenAuthors = authors.filter(([login]) => hiddenSet.has(login)); + + const toggleScope = (login: string, all: string[]) => + setScope(toggleScopeMember(scope, 'authors', login, all)); + const included = (login: string) => !scope.authors.length || scope.authors.includes(login); + // the "everyone except" lane: per-login, mutually exclusive with the + // allow-list (excluding someone pulls them out of authors, and vice versa) + const exclude = (login: string) => + setScope({ + ...scope, + authors: scope.authors.filter(l => l !== login), + notAuthors: [...new Set([...scope.notAuthors, login])], + }); + const include = (login: string) => + setScope({ ...scope, notAuthors: scope.notAuthors.filter(l => l !== login) }); + + // full words for the hover title / aria; the badge abbreviates to a count + // (or a −count when the selection is an exclusion) + const value = + scope.authors.length === 0 + ? null + : scope.authors.length === 1 + ? scope.authors[0] + : `${scope.authors.length} people`; + const excludingWords = + scope.notAuthors.length === 0 + ? null + : `excluding ${ + scope.notAuthors.length === 1 + ? scope.notAuthors[0] + : `${scope.notAuthors.length} people` + }`; + const words = + value && excludingWords ? `${value} · ${excludingWords}` : (value ?? excludingWords); + const badge = scope.authors.length + ? String(scope.authors.length) + : scope.notAuthors.length + ? `−${scope.notAuthors.length}` + : null; + + const filteredShown = shownAuthors.filter(([login]) => + matchesPerson(login, peopleQuery.toLowerCase()) + ); + const filteredHidden = hiddenAuthors.filter(([login]) => + matchesPerson(login, peopleQuery.toLowerCase()) + ); + + return ( +
+ ( + + )} + > + + {filteredShown.map(([login, count]) => { + const excluded = scope.notAuthors.includes(login); + return ( + + { + if ((e.nativeEvent as MouseEvent).shiftKey) + return excluded ? include(login) : exclude(login); + excluded + ? include(login) + : toggleScope( + login, + authors.map(([l]) => l) + ); + }} + ariaLabel={nameOf(login) ?? login} + leading={} + title={login} + textClassName={`min-w-0 flex-1 truncate text-[13px] ${ + excluded ? 'text-ink-3 line-through' : '' + }`} + > + {nameOf(login) ?? login} + + {/* "only" leads the right cluster: it fades in on hover, so + it must not interject between the standing count and eye */} + setScope({ ...scope, authors: [login], notAuthors: [] })} + /> + {count || ''} + {login !== me && ( + + ); + })} + {filteredShown.length === 0 && ( +
No one matches.
+ )} + {filteredHidden.length > 0 && ( +
+ + {/* the caret is the fold affordance — without it this row + read as a label, not a door (owner report) */} + + Hidden people ({filteredHidden.length}) + + {filteredHidden.map(([login, count]) => ( + + + + + {nameOf(login) ?? login} + + + {count || ''} + + + + ))} +
+ )} + 0 || scope.notAuthors.length > 0} + onClear={() => setScope({ ...scope, authors: [], notAuthors: [] })} + /> +
+
+ ); +} diff --git a/frontend-v2/src/components/filters/RepoFilter.tsx b/frontend-v2/src/components/filters/RepoFilter.tsx new file mode 100644 index 00000000..172537ec --- /dev/null +++ b/frontend-v2/src/components/filters/RepoFilter.tsx @@ -0,0 +1,259 @@ +import { useState } from 'react'; +import { ChevronRight, GripVertical } from 'lucide-react'; +import { repoHidden } from '../../../../shared/model/visibility'; +import { shortRepo } from '../../../../shared/format'; +import { toggleScopeMember, type Scope } from '../../prefs'; +import { setRepoPref, setSettings, useSettings } from '../../settings'; +import { Icon } from '../Icon'; +import { Popover } from '../Popover'; +import { + CheckboxField, + ClearRow, + EyeButton, + FilterRow, + FilterSearch, + FilterTrigger, + OnlyButton, +} from './shared'; + +/** + * The repos filter: opens straight into the repo list (no tabs — the old + * 3-tab Filters popover split repos/people/drafts behind a Segmented click + * you had to make before you could even search). Scope (which repos are on + * your board right now) and hide (which repos are off your board, period) + * live side by side on each row. Hiding is per-user only: the server + * config's v1-era org-level mute is ignored — each user hides a repo once, + * their call. + * + * The list's ORDER is load-bearing: rows sort by settings.repoPriority — the + * same order the review queue renders its repo blocks in — and the grip + * handle reorders it (drag, or arrow keys on the focused grip). This panel + * is the one place the order changes; Settings keeps only the per-repo cap. + * (Parked PRs and drafts used to ride along here as session toggles; they're + * board-level hiding, so they live in the filter bar's hidden-PR ledger now — + * see HiddenPanel.) + */ +export function RepoFilter({ + repos, + reveal, + toggleReveal, + showAll, + setShowAll, + scope, + setScope, +}: { + /** all repos with open-PR counts (user-hidden included) */ + repos: { name: string; count: number }[]; + reveal: string[]; + toggleReveal: (key: string) => void; + showAll: boolean; + setShowAll: (next: boolean) => void; + scope: Scope; + setScope: (next: Scope) => void; +}) { + const settings = useSettings(); + const prefs = settings.repoPrefs; + const priority = settings.repoPriority; + const [repoQuery, setRepoQuery] = useState(''); + const [dragging, setDragging] = useState(null); + const [dropTarget, setDropTarget] = useState(null); + + // the list shows YOUR order: priority-listed repos first, in that order + // (stable sort keeps the incoming count order among the unlisted tail) + const orderOf = (name: string) => { + const i = priority.indexOf(name); + return i === -1 ? Number.POSITIVE_INFINITY : i; + }; + const shownRepos = repos + .filter(r => !repoHidden(r.name, prefs)) + .sort((a, b) => orderOf(a.name) - orderOf(b.name)); + const hiddenRepos = repos.filter(r => repoHidden(r.name, prefs)); + + // any reorder writes the full shown order as the priority — after the + // first drag every visible repo is explicitly placed, which is exactly + // what the user just expressed + const reorder = (name: string, target: string) => { + if (name === target) return; + const order = shownRepos.map(r => r.name); + order.splice(order.indexOf(name), 1); + // inserting at the target's post-removal index lands before it when + // dragging up and after it when dragging down — the intuitive drop + order.splice(order.indexOf(target), 0, name); + setSettings({ repoPriority: order }); + }; + const nudge = (name: string, key: string) => { + const order = shownRepos.map(r => r.name); + const i = order.indexOf(name); + const j = key === 'ArrowUp' ? i - 1 : i + 1; + if (j < 0 || j >= order.length) return; + [order[i], order[j]] = [order[j], order[i]]; + setSettings({ repoPriority: order }); + }; + + const toggleScope = (name: string, all: string[]) => + setScope(toggleScopeMember(scope, 'repos', name, all)); + const included = (name: string) => !scope.repos.length || scope.repos.includes(name); + + // the trigger names only what narrows the board: a lone repo by name, more + // as a count. Hidden counts are durable state — they read in the hidden-PR + // ledger, not here, so the trigger stays quiet when nothing is filtered. + const value = + scope.repos.length === 0 + ? null + : scope.repos.length === 1 + ? shortRepo(scope.repos[0]) + : `${scope.repos.length} repos`; + + const shownRow = (name: string, count: number) => ( + { + if (!dragging) return; + e.preventDefault(); + setDropTarget(name); + }} + onDrop={e => { + e.preventDefault(); + if (dragging) reorder(dragging, name); + setDragging(null); + setDropTarget(null); + }} + // an inset shadow, not a border: the drop indicator must not move + // the rows it's pointing between + style={ + dropTarget === name && dragging !== name + ? { boxShadow: 'inset 0 2px 0 0 var(--brand)' } + : undefined + } + > + { + setDragging(name); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', name); + }} + onDragEnd={() => { + setDragging(null); + setDropTarget(null); + }} + onKeyDown={e => { + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return; + e.preventDefault(); + nudge(name, e.key); + }} + > + + + + toggleScope( + name, + shownRepos.map(r => r.name) + ) + } + ariaLabel={`filter to ${shortRepo(name)}`} + title={name} + > + {shortRepo(name)} + + {/* "only" leads the right cluster: it fades in on hover, so it must + not interject between the standing count and eye */} + setScope({ ...scope, repos: [name] })} /> + {count || ''} + + ); + + const hiddenRow = (name: string, count: number) => ( + + toggleReveal(name)} + ariaLabel={`reveal ${shortRepo(name)} for now`} + title={name} + textClassName="min-w-0 flex-1 truncate text-[13px] text-ink-3" + count={count || ''} + > + {shortRepo(name)} + + + ); + + const filteredShown = shownRepos.filter(r => + shortRepo(r.name).toLowerCase().includes(repoQuery.toLowerCase()) + ); + const filteredHidden = hiddenRepos.filter(r => + shortRepo(r.name).toLowerCase().includes(repoQuery.toLowerCase()) + ); + + return ( +
+ ( + + )} + > + + {/* hidden repos ride at the top, collapsed, so the one-click + reveal is the first thing you reach — matching the Settings + repo manager's hidden→shown order */} + {filteredHidden.length > 0 && ( +
+ + {/* the caret is the fold affordance — without it this row + read as a label, not a door (owner report) */} + + Hidden by you ({filteredHidden.length}) + + + {filteredHidden.map(r => hiddenRow(r.name, r.count))} +
+ )} + {filteredShown.map(r => shownRow(r.name, r.count))} + {filteredShown.length === 0 && ( +
No repos match.
+ )} + 0} + onClear={() => setScope({ ...scope, repos: [] })} + /> +
+
+ ); +} diff --git a/frontend-v2/src/components/filters/StateFilter.tsx b/frontend-v2/src/components/filters/StateFilter.tsx new file mode 100644 index 00000000..2bdcd9c8 --- /dev/null +++ b/frontend-v2/src/components/filters/StateFilter.tsx @@ -0,0 +1,76 @@ +import type { ActionStateKey } from '../../model/actions'; +import { actionState } from '../../model/actions'; +import type { DerivedPull } from '../../../../shared/model/status'; +import { usePulldasher } from '../../store'; +import { ChecklistFilter } from './shared'; + +/** + * State filter option order: what's waiting on you first (the thing you're + * most likely to be hunting for), then the two reviewer verbs, then the + * re-stamp lane, then the two states with no action of your own. + */ +const STATE_OPTIONS: { key: ActionStateKey; label: string }[] = [ + { key: 'mine', label: 'Waiting on me' }, + { key: 'review', label: 'Review it' }, + { key: 'qa', label: 'QA it' }, + { key: 'restamp', label: 'Re-stamp owed' }, + { key: 'blocked', label: 'Blocked' }, + { key: 'waiting', label: 'Waiting' }, +]; + +/** + * The point-and-click twin of `has:action` / `is:restamp` / `is:blocked`: + * narrow the board to one or more actionState buckets (model/actions.ts). + * `pulls` is the pre-state-filtered pool (app.tsx's scoped list after the + * Weight pass but before this filter's own pass) so a selection never makes + * the *other* options' counts vanish — the same precedent PeopleFilter's + * authorCounts sets for its own pool. + */ +export function StateFilter({ + pulls, + stateSel, + setStateSel, +}: { + pulls: DerivedPull[]; + stateSel: ActionStateKey[]; + setStateSel: (next: ActionStateKey[]) => void; +}) { + const { me } = usePulldasher(); + + const counts = new Map(); + for (const p of pulls) { + const key = actionState(p, me); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + + const toggle = (key: ActionStateKey) => { + const next = stateSel.includes(key) ? stateSel.filter(k => k !== key) : [...stateSel, key]; + setStateSel(next); + }; + + // one selection names itself; more collapse to "first +N" so the trigger + // can't grow as long as the option labels themselves + const first = STATE_OPTIONS.find(o => o.key === stateSel[0])?.label ?? stateSel[0]; + const value = + stateSel.length === 0 + ? null + : stateSel.length === 1 + ? first + : `${first} +${stateSel.length - 1}`; + + return ( + setStateSel([key])} + onClear={() => setStateSel([])} + /> + ); +} diff --git a/frontend-v2/src/components/filters/WeightFilter.tsx b/frontend-v2/src/components/filters/WeightFilter.tsx new file mode 100644 index 00000000..590880a7 --- /dev/null +++ b/frontend-v2/src/components/filters/WeightFilter.tsx @@ -0,0 +1,71 @@ +import type { DerivedPull } from '../../../../shared/model/status'; +import { weightFilterKey } from '../../../../shared/model/status'; +import { ChecklistFilter } from './shared'; + +/** Weight filter option order: lightest to heaviest — mirrors the rail's + * own weight-letter read (XS through XL). */ +const WEIGHT_OPTIONS: { key: string; label: string }[] = [ + { key: 'xs', label: 'XS · very light' }, + { key: 's', label: 'S · light' }, + { key: 'm', label: 'M · medium' }, + { key: 'l', label: 'L · heavy' }, + { key: 'xl', label: 'XL · very heavy' }, +]; + +/** + * The point-and-click twin of the `weight:` query token: narrow the board to + * one or more review-effort classes. `pulls` is the pre-weight-filtered + * pool (app.tsx's scoped list before this filter's own pass applies) so a + * selection never makes the *other* options' counts vanish — the same + * precedent PeopleFilter's authorCounts sets for its own pool. + */ +export function WeightFilter({ + pulls, + weightSel, + setWeightSel, +}: { + pulls: DerivedPull[]; + weightSel: string[]; + setWeightSel: (next: string[]) => void; +}) { + const counts = new Map(); + for (const p of pulls) { + const key = weightFilterKey(p); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + + const toggle = (key: string) => { + const next = weightSel.includes(key) ? weightSel.filter(k => k !== key) : [...weightSel, key]; + setWeightSel(next); + }; + + // the rail spells weights as letters (XS…XL); the trigger matches it + const value = weightSel.length ? weightSel.map(k => k.toUpperCase()).join(', ') : null; + + return ( + setWeightSel([key])} + onClear={() => setWeightSel([])} + /> + ); +} diff --git a/frontend-v2/src/components/filters/shared.tsx b/frontend-v2/src/components/filters/shared.tsx new file mode 100644 index 00000000..b0b8df6e --- /dev/null +++ b/frontend-v2/src/components/filters/shared.tsx @@ -0,0 +1,377 @@ +import type { ChangeEvent, ComponentPropsWithoutRef, ReactNode } from 'react'; +import { ChevronDown, Eye, EyeClosed } from 'lucide-react'; +import { Icon } from '../Icon'; +import { Popover, type PopoverTriggerProps } from '../Popover'; +import { CornerBadge, QuietButton, textInputClass } from '../bits'; + +/** + * The one filter-bar trigger: a quiet text-level control whose geometry NEVER + * changes. The dimension name is the whole trigger; the active state rides as + * a small corner badge of 1–3 characters — a count, or the value itself when + * it fits (a lone weight's "S"), or a −count for an excluding selection — so + * narrowing the board can't reflow the bar. Full words live in the hover + * title and aria-label; the panel behind the click is the detail view, and + * clearing lives in the panel too (ClearRow), not as an appearing ×. + */ +export function FilterTrigger({ + t, + label, + badge, + badgeTone = 'brand', + active = false, + title, + ariaLabel, +}: { + t: PopoverTriggerProps; + /** the dimension name, always visible: Repos, People, Weight… */ + label: string; + /** 1–3 character summary of the active state; null/empty = no badge */ + badge?: string | null; + /** 'brand' = a narrowing you chose; 'quiet' = standing info (the hidden + * ledger's count) */ + badgeTone?: 'brand' | 'quiet'; + /** a standing non-badge state (e.g. "showing hidden") — carried by color + * only, so the trigger still never moves */ + active?: boolean; + /** full-words active state for the hover title (the badge abbreviates) */ + title?: string; + ariaLabel: string; +}) { + return ( + + ); +} + +/** + * The panel-footer clear for one dimension — the home the bar's per-trigger × + * moved into when triggers went geometry-constant. Renders nothing while the + * dimension is inactive, so panels only offer what would do something. + */ +export function ClearRow({ active, onClear }: { active: boolean; onClear: () => void }) { + if (!active) return null; + return ( +
+ Clear +
+ ); +} + +/** + * Whether any TRANSIENT session filter is narrowing (or revealing) the board + * right now — what the bar's "Reset" resets, and what the saved-filters + * panel reads to decide whether there's anything worth bookmarking. One + * definition so the two surfaces can't disagree about what counts as + * "active". Durable state (hidden, defaults) is deliberately not here. + */ +export function hasActiveFilters({ + reveal, + showAll, + draftsMode, + defaultDraftsMode, + scope, + weightSel, + stateSel, +}: { + reveal: string[]; + showAll: boolean; + draftsMode: 'mine' | 'all'; + defaultDraftsMode: 'mine' | 'all'; + scope: { repos: string[]; authors: string[]; notAuthors: string[] }; + weightSel: string[]; + stateSel: string[]; +}): boolean { + return ( + scope.repos.length > 0 || + scope.authors.length > 0 || + scope.notAuthors.length > 0 || + weightSel.length > 0 || + stateSel.length > 0 || + reveal.length > 0 || + showAll || + draftsMode !== defaultDraftsMode + ); +} + +/** + * The one filter-panel search input, replacing the three copy-pasted + * `h-8 w-full rounded-lg...` text inputs that used to live separately in + * Filters' repo tab, its people tab, RepoManager, and TeamPicker. + */ +export function FilterSearch({ + value, + onChange, + label, + className = '', +}: { + value: string; + onChange: (next: string) => void; + label: string; + className?: string; +}) { + return ( + ) => onChange(e.target.value)} + aria-label={label} + placeholder={label} + type="text" + className={`mb-2 w-full px-2.5 ${textInputClass} ${className}`} + /> + ); +} + +/** + * The hover-highlighted row shell every checkbox/candidate list on the board + * wraps its rows in (RepoFilter, PeopleFilter, WeightFilter, StateFilter): + * one definition so the row geometry (padding, radius, hover tint) can't + * drift between lists. Also the shared substring behind SavedFilterRow's and + * TeamPicker's CandidateRow's hand-copies of the same recipe (see + * `hoverRowClass`) — those rows differ just enough (no `group`, a different + * gap, an extra text-size class) that they compose it themselves rather than + * rendering through this component. + */ +export const hoverRowClass = + 'flex items-center rounded-md px-1.5 py-[5px] transition-[background-color] duration-150 ease-out hover:bg-muted motion-reduce:transition-none'; + +export function FilterRow({ + children, + ...rest +}: { children: ReactNode } & ComponentPropsWithoutRef<'div'>) { + return ( +
+ {children} +
+ ); +} + +/** + * The labelled-checkbox inner block every checkbox row on the board shares + * (WeightFilter, StateFilter, RepoFilter, HiddenPanel, PeopleFilter, + * TeamPicker's CandidateRow): a native checkbox, an optional leading slot + * (an avatar), the option's text, and an optional trailing count — one + * definition so the five copy-pasted versions of this can't drift. The row + * wrapper (FilterRow or a caller's own div) and any sibling OnlyButton/ + * EyeButton stay at the call site — this only ever renders the `