Migration tracker
Setup, data model, API and tickets.
Registry, dashboard and API for migrating afl.com.au to Next.js and Optimizely. It tracks items of different kinds, each moving through its own pipeline of stages:
| Kind | Pipeline |
|---|---|
widget (parity track) | discovered > migrated_unverified > audited > stubbed > built > verified > live |
widget (redesign track) | discovered > designed > built > reviewed > approved > live |
content_type | understood > designed > implemented > migrated |
page (parity track) | discovered > content_migrated > verified > live |
migrated_unverified marks widgets backfilled from an earlier port that has not been verified.
Each item has a track: parity copies the live widget, redesign rebuilds it from the design
system. Stages are keyed by kind and track. merged (outside the pipeline, not shown in charts)
marks a widget that was combined into a redesign; merged_into points at it.
Stages are rows in the stages table, not enums, so a new kind of tracked work is a new set of
stage rows. Fixtures, HARs and baselines stay in git; the tracker only indexes their paths.
Setup
-
Create a Supabase project.
-
Link it and apply the schema and the page seed:
cd apps/migration-tracker bunx supabase@2.120.0 link --project-ref <project-ref> bunx supabase@2.120.0 db push --include-seed -
Copy
.env.exampleto.env.localand fill it in:Variable Purpose SUPABASE_URLProject URL SUPABASE_SERVICE_ROLE_KEYServer-only key; the app and scripts write with it TRACKER_API_TOKENBearer token required by every write endpoint TRACKER_URLBase URL used by scripts/ticket.tsandparity:publishJIRA_BASE_URLe.g. https://<site>.atlassian.netJIRA_EMAILAccount that owns JIRA_API_TOKENJIRA_API_TOKENJira Cloud API token JIRA_PROJECT_KEYDefaults to AFLSFJIRA_ISSUE_TYPEDefaults to TaskJIRA_FACTORY_ACCOUNT_IDSoftware Factory agent account, set as the ticket assignee LINEAR_API_KEYLinear API key; when set, tickets go to Linear, not Jira LINEAR_TEAM_KEYTeam the issues are created in, e.g. AFLLINEAR_WORKSPACEWorkspace URL slug, for issue links LINEAR_PROJECT_IDOptional project for the issues LINEAR_FACTORY_DELEGATE_IDSoftware Factory agent user the issue is delegated to LEGACY_CMS_BASE_URL,LEGACY_CMS_API_TOKENLegacy CMS the content pipeline fetches from OPTIMIZELY_CLIENT_ID,OPTIMIZELY_CLIENT_SECRETContent Management API credentials (also OPTIMIZELY_CMA_URL,OPTIMIZELY_TOKEN_URL) -
Load the backlog and the seed, then run the app:
bun run import-backlog # Pulse widgets, legacy folders, RFP features from data/backlog bun run seed # pages and items from data/seed.json bun run import-backlog # again, to link features to the newly seeded items bun run dev # https://migration-tracker.afl.localhost via portlessBoth scripts are idempotent. Re-seeding never moves an existing item's status; status only changes through
PATCH /api/items/:id.
Scraping site pages (Migration tab)
The Migration tab lists every URL in the sitemaps of the existing sites, as page items. The sites
(afl.com.au and the club sites) are configured in data/sites.json; add or fix a site there.
bun run scrape-sitemaps # all sites, or name some: bun run scrape-sitemaps afl carlton
bun run import-pages # needs SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEYPer site, sitemapPath (for example "/news-sitemap") names the sitemap path and skips
robots.txt; sitemaps takes full URLs. With neither, the scraper reads robots.txt and, if that
lists no sitemap, tries /sitemap first, then other common paths. The scraper reads robots.txt, follows sitemap indexes (including gzipped sitemaps),
waits 500 ms between requests and identifies itself with an AFL-MigrationTracker user agent. A
failing site or sitemap is reported and skipped; the other sites still run, and a failed site keeps
its previous data/sitemap/<site>.json. Scrapes are written to data/sitemap/ and are not
committed by default.
Each URL is classified by the ordered path rules in src/lib/page-types.ts (news article, match
centre, player profile, team page, ...); anything that matches no rule is unclassified and shown
on the tab for review. import-pages only inserts pages it has not seen, keyed by site and URL, so
re-running it never moves an existing page's status. Pages that later disappear from a sitemap are
kept. Apply supabase/migrations/0005_page_kind.sql first (supabase db push). The "Last refreshed"
date under the Refresh button needs 0007_sitemap_refresh.sql.
Running on Vercel
vercel.json runs GET /api/sync-pages daily (17:00 UTC). It scrapes every site in
data/sites.json and inserts new pages straight into the database, the same as the two scripts
above but without the JSON files. Set CRON_SECRET in the Vercel project; Vercel Cron sends it as
a bearer token. To run it by hand, call it with CRON_SECRET or TRACKER_API_TOKEN, optionally
limited to some sites:
curl -H "Authorization: Bearer $TRACKER_API_TOKEN" "https://<app>/api/sync-pages?site=afl,carlton"The run is capped at 300 s (maxDuration). If the full list outgrows that, call it per site.
Content migration
The Migration tab also runs a five-step pipeline (fetch, map, normalise, validate, migrate) that moves
content from the legacy CMS into Optimizely on Vercel Queues. Apply
supabase/migrations/0006_content_migration.sql. See content/docs/content-migration-pipeline.md and
content/docs/run-content-migration.md.
Deploying
Vercel project with root directory apps/migration-tracker, framework Next.js, and the env vars above.
Every page and route is dynamic, so the build needs no Supabase credentials.
Data model
stages(kind, key, position): the pipelines.items(kind, status)references it, so a status can only be a stage of its own kind.items: one row per tracked thing;idis a kebab-case slug.metaholds anything kind-specific.pagesanditem_pages: where an item appears (region, position).dependencies: e.g. a widget depends on a content type.artifacts:fixture | har | baseline | spec | requirements | stories | data_model | mapping | config_instructions | pr | other, with a gitpathor auri. The fixture index is the artifacts of kindfixture.field_mappings: source to target field mappings, with an optional transform.test_runs: parity results per commit, matrix cell (must-watch/1280/light/carl) and check:screenshot,styles,tokensorbehaviour:<name>(for examplebehaviour:hover headline).warnmarks a passed result whose screenshot differed within the harness threshold (passedstays true, so it counts as a pass).rebuilds: one row per rebuild (trigger, actor fromx-tracker-actor, commit, optional Software Factory job, PR and rule id, change-log note, and a ticket reference of typeoriginal,childorchangelog). Sequence numbers are allocated per item byrecord_rebuild.events: written by a trigger on every status change. The actor is thex-tracker-actorrequest header (the API sets it from theactorfield onPATCH).pulse_widgets,item_pulse_widgets: the legacy Pulse CMS widgets with AFL and club usage; one item can replace several Pulse widgets.legacy_folders:aflm-webwidget source folders, matched to Pulse widgets by display name.features,item_features: RFP epics and features, linked to items whose id orlegacy_refmatches the feature's mapped widget folder.- Views:
item_summary(items plus page ids and the latest commit's pass/fail counts, counting the newest run per cell and check) andpulse_summary(Pulse widgets plusin_useand the coveringitem_ids).
RLS is on for every table: anon and authenticated can only read. All writes go through the
API, which uses the service role key.
Item writes are atomic. POST /api/items and PATCH /api/items/:id call one database function,
save_item, which updates the item and replaces any supplied pages, dependencies and Pulse links
in a single transaction. If any part fails (an unknown page, a missing dependency, a stage that
is not in the item's pipeline), nothing changes.
Content-model pipeline
Content types reuse the same tables. An item of kind content_type moves through understood
(the Pulselive data model is understood), designed (the Optimizely model is designed),
implemented and migrated. Its outputs are artifacts: data_model (Excel model),
mapping (Excel mappings), requirements (Confluence functional requirements), stories
(Jira stories) and config_instructions. Field-level mappings go in field_mappings, and widgets
that render the type list it in dependsOn.
To add another kind, insert its stages and start creating items of that kind; no code changes.
API
Responses are JSON with snake_case columns. Request bodies are camelCase and validated with Zod.
Writes need Authorization: Bearer $TRACKER_API_TOKEN. Errors are { "error": "..." } with
400 (invalid body or status), 401, 404, 409 or 502 (Jira).
| Method | Path | Body |
|---|---|---|
GET | /api/items?kind=&status=&page= | List from item_summary |
GET | /api/items/:id | Item with pages, dependencies, dependents, artifacts, mappings, test runs, events, Pulse widgets, features |
POST | /api/items | Upsert an item (shape below) |
PATCH | /api/items/:id | Any item field except id/kind, plus optional actor; status must be a stage of the item's kind |
POST | /api/items/:id/artifacts | { kind, path?, uri?, meta? } or an array |
POST | /api/items/:id/test-runs | [{ commitSha, cell, check, passed, warn?, detail? }] |
POST | /api/items/:id/screenshots | Multipart path and file (PNG); stores it in the private parity-screenshots bucket under <id>/<path> and returns { path } |
POST | /api/items/:id/mappings | { source, target, transform?, notes? } or an array |
POST | /api/items/combine | { id, name, sourceIds, type?, notes?, designComponent? }: creates a redesign item from two or more widgets, moves them to merged and rolls up their pages, Pulse widgets, features and dependencies |
POST | /api/items/:id/approve | { approvedBy }: moves a reviewed redesign to approved and records who and when |
POST | /api/items/:id/rebuilds | Records a rebuild (trigger required; commitSha, changeLog, sfJob, prUrl, ruleId optional). Actor from x-tracker-actor. track: true also files a child ticket under the original one |
GET | /api/items/:id/rebuilds | Lists the item's rebuilds, newest first |
POST | /api/items/:id/ticket | Creates (or reuses) the Jira ticket for a widget and stores jira_key |
POST | /api/items/:id/rebuilds | Records a rebuild (reason, sfJob, actor); reuses the item's ticket, never opens another |
GET | /api/reports/regeneration | Rebuilds grouped by Optimizely content model: items, rebuilds, tickets reused, most rebuilt item |
Item shape (also the data/seed.json item shape):
{
"id": "article-list-sidebar-must-watch",
"kind": "widget",
"name": "Article list sidebar ('Must Watch')",
"type": "element",
"status": "verified",
"priority": "high",
"action": "rebuild",
"legacyRef": "src/widgets/sites/aflm/article-list/article-list-must-watch.ftl",
"optimizelyType": "ArticleListElement",
"themeable": false,
"jiraKey": null,
"sfJob": null,
"owner": null,
"notes": null,
"meta": {},
"pages": [{ "page": "article", "region": "sidebar", "position": 19 }],
"dependsOn": ["article"],
"pulseIds": [105]
}type is one of element | shared-block | page-section | chrome | behaviour | content-type.
priority is one of highest | high | medium | low | lowest (Jira's scale) or null; set it on
the dashboard or through the API. action is the migration decision from the master widget list:
rebuild | create_from_scratch | dont_migrate | combine, or null until decided (apply
supabase/migrations/0009_item_action.sql). Set it on the item page or through the API; the
explorer filters on it. sfJob is the Software Factory job number; both skills set it when a run starts. Re-seeding keeps
both. track switches between parity and redesign; switching moves the status to the first
stage of the new track unless the current one exists there. designComponent and figmaUrl are
the AFLDS-Web-Library component a redesign follows.
pages, dependsOn and pulseIds, when present, replace the existing links.
Tickets
Tickets go to Linear when LINEAR_API_KEY is set and to Jira otherwise. Either way the key
(AFLSF-12 or AFL-12) is stored in the item's jira_key.
src/lib/ticket.ts builds the content: title Migrate widget: <name> (<id>), and a
description that names the migrate-afl-widget skill, the widget id, legacy ref, Optimizely
type, the Pulse widgets it replaces with their usage, its pages and live sample URL, and the
definition of done. src/lib/jira.ts renders it as ADF into JIRA_PROJECT_KEY with labels
software-factory, widget-migration and tracker-<item-id>, assigned to
JIRA_FACTORY_ACCOUNT_ID. src/lib/linear.ts renders it as markdown into the LINEAR_TEAM_KEY
team with labels software-factory and widget-migration (created on the team if missing), and
delegates it to LINEAR_FACTORY_DELEGATE_ID, which hands it to the agent.
Creation cannot duplicate issues. The route first reserves the item's jira_key with a
conditional update, so a second request at the same time gets 409. It then looks for an issue
for the item (labelled tracker-<item-id> in Jira, titled … (<item-id>) in Linear) and reuses
it, which makes a retry after a failure safe, and only creates one if none exists. If Jira or
Linear fails, the reservation is released.
Rebuilds
jira_key stays the item's original build ticket: a rebuild never creates a new top-level
ticket. POST /api/items/:id/rebuilds always writes the tracker-side change log (a rebuilds
row). It references the original ticket (original), or nothing (changelog) when the item has
none yet. With track: true it also files a child ticket under the original one (child): a Jira
Sub-task labelled software-factory, widget-rebuild, tracker-<item-id> and
tracker-<item-id>-rebuild-<n>, or a Linear child issue titled … (<item-id> rebuild <n>).
Both are looked up by that label or suffix before creating, so a retry reuses the ticket. track
without an original ticket is a 409. If the ticket system fails the change log is kept as
original/changelog and the response carries ticket_error.
bun run ticket article-list-sidebar-must-watch --dry-run # print the ticket
bun run ticket article-list-sidebar-must-watch # create it via the APIDashboard
Built with shadcn/ui (Base UI, the same base-nova style as packages/ui) and Recharts, themed
with the afl.com.au tokens from @afl/config and Inter (src/app/globals.css maps them onto the
shadcn variables). Add components with bunx shadcn@4.21.4 add <name> from this folder.
/: headline numbers, overall progress and charts (items by stage, progress over time from status history, items by type, priority)./widgets: the item list with instant search, stage, type, page and priority filters, an editable priority per item and the SF job. Filters live in the URL./items/:id: a stage stepper, priority, SF job, Jira, the parity results grid with screenshot comparison, Pulse widgets, pages, features, dependencies, artifacts, mappings and history./map: a tree of every page, its regions and the widgets placed there, with the Pulse widgets, features and merged widgets linked to each. Hovering or clicking a Pulse widget or feature highlights every widget it links to./pulse: the most used Pulse widgets as a chart, then a searchable, sortable list./docs: the framework docs, built with Fumadocs inside this app'snext build. The widget migration overview and the legacy audit live here as markdown incontent/docs. The other pages pull in a file from elsewhere in the repo with<include>(both skills, the parity harness and this README), so every doc has one source.src/lib/repo-docs.tsdrops an included file's own title and rewrites relative links: to the docs page when the target is a docs page or included in one, otherwise to GitHub. The diagrams are static pages inpublic/diagrams, generated bypython3 scripts/build-diagrams.py public/diagrams. Search is/api/search. To add a page, add a.mdor.mdxfile with atitleand adescription(and an<include>if the text lives elsewhere), and list it in that folder'smeta.json.
On the dashboard, select widgets and choose "Combine into redesign" to create one redesign item
from them. An item page has "Rebuild from designs" (or "Back to parity"), shows the design system
component and Figma link for redesigns, and an "Approve design" button once a redesign is
reviewed; the approver's name is remembered in the browser. Redesign tickets name the
redesign-afl-widget skill and ask for no pixel parity.
Priority changes, combining, approving and track switches from the dashboard go through server actions (src/app/actions.ts) with the
service key, so anyone who can open the dashboard can make these changes. Keep the deployment
behind Vercel authentication.
Parity results
bun run --cwd tools/parity parity:publish uploads the run's screenshots to
/api/items/:id/screenshots, posts the results to /api/items/:id/test-runs with the stored
screenshot paths in detail.screenshots, and exits with an error if the tracker rejects either.
The item page shows only the latest commit's results, newest per cell and check. Select a result
to compare afl.com.au with the build side by side, with a slider, or as a difference image
(identical pixels are black). Warnings (yellow !) pass but are shown apart from clean passes,
with the yellow-highlighted diff image under the comparison. Screenshots are served through signed URLs that last an hour.
Migration 0002_parity_screenshots.sql creates the bucket; each run stores about 4 MB per widget.
Migration 0003_priority_and_sf_job.sql adds priority and sf_job. Migration
0004_redesign_track.sql adds tracks, the redesign stages, merged, the design and approval
fields, the figma and a11y checks and the combine_items function.
Seed data
data/seed.json holds the Article page and its 35 widgets from
data/legacy-audit.json: earlier ports as
migrated_unverified, the rest as discovered. Their themeable flags are provisional
(meta.themeableProvisional) until the factory checks each widget's club version.
data/backlog/ is the backlog spreadsheet exported to JSON for import-backlog.
Checks
bun run typecheck
bun run build