diff --git a/packages/db/script/seed-external.ts b/packages/db/script/seed-external.ts new file mode 100644 index 0000000000..8deb6633e6 --- /dev/null +++ b/packages/db/script/seed-external.ts @@ -0,0 +1,27 @@ +import { createClient } from "@libsql/client"; +import { drizzle } from "drizzle-orm/libsql"; + +import { env } from "../env.mjs"; +import { externalService } from "../src/schema"; +import { externalServicesSeed } from "../src/seed/external-services"; + +async function main() { + const db = drizzle( + createClient({ url: env.DATABASE_URL, authToken: env.DATABASE_AUTH_TOKEN }), + ); + console.log("Seeding database "); + + await db + .insert(externalService) + .values(externalServicesSeed) + .onConflictDoNothing() + .run(); + + process.exit(0); +} + +main().catch((e) => { + console.error("Seed failed"); + console.error(e); + process.exit(1); +}); diff --git a/packages/db/src/seed/external-services.ts b/packages/db/src/seed/external-services.ts index 7abd87b63e..0fe4783a6b 100644 --- a/packages/db/src/seed/external-services.ts +++ b/packages/db/src/seed/external-services.ts @@ -189,4 +189,499 @@ export const externalServicesSeed: ExternalServiceSeedEntry[] = [ description: "E-commerce platform for online stores", apiConfig: { type: "atlassian" }, }, + { + slug: "1password", + name: "1Password", + url: "https://1password.com", + statusPageUrl: "https://status.1password.com", + provider: "atlassian-statuspage", + industry: ["security", "saas"], + description: "Password manager for individuals, families, and teams", + apiConfig: { type: "atlassian" }, + }, + { + slug: "airtable", + name: "Airtable", + url: "https://www.airtable.com", + statusPageUrl: "https://status.airtable.com", + provider: "atlassian-statuspage", + industry: ["saas"], + description: + "Cloud-based collaboration platform combining databases and spreadsheets", + apiConfig: { type: "atlassian" }, + }, + { + slug: "amplitude", + name: "Amplitude", + url: "https://amplitude.com", + statusPageUrl: "https://status.amplitude.com", + provider: "atlassian-statuspage", + industry: ["monitoring"], + description: "Product analytics platform", + apiConfig: { type: "atlassian" }, + }, + { + slug: "appsflyer", + name: "AppsFlyer", + url: "https://www.appsflyer.com", + statusPageUrl: "https://status.appsflyer.com", + provider: "atlassian-statuspage", + industry: ["monitoring"], + description: "Mobile attribution and marketing analytics platform", + apiConfig: { type: "atlassian" }, + }, + { + slug: "bitbucket", + name: "Bitbucket", + url: "https://bitbucket.org", + statusPageUrl: "https://bitbucket.status.atlassian.com", + provider: "atlassian-statuspage", + industry: ["development-tools"], + description: "Git code hosting for teams", + apiConfig: { type: "atlassian" }, + }, + { + slug: "bitly", + name: "Bitly", + url: "https://bitly.com", + statusPageUrl: "https://status.bitly.com", + provider: "atlassian-statuspage", + industry: ["saas"], + description: "Link management and URL shortening platform", + apiConfig: { type: "atlassian" }, + }, + { + slug: "box", + name: "Box", + url: "https://www.box.com", + statusPageUrl: "https://status.box.com", + provider: "atlassian-statuspage", + industry: ["saas"], + description: "Cloud content management and file sharing", + apiConfig: { type: "atlassian" }, + }, + { + slug: "circleci", + name: "CircleCI", + url: "https://circleci.com", + statusPageUrl: "https://status.circleci.com", + provider: "atlassian-statuspage", + industry: ["development-tools"], + description: "Continuous integration and delivery platform", + apiConfig: { type: "atlassian" }, + }, + { + slug: "cloudinary", + name: "Cloudinary", + url: "https://cloudinary.com", + statusPageUrl: "https://status.cloudinary.com", + provider: "atlassian-statuspage", + industry: ["cdn", "development-tools"], + description: "Image and video management cloud service", + apiConfig: { type: "atlassian" }, + }, + { + slug: "cloudways", + name: "Cloudways", + url: "https://www.cloudways.com", + statusPageUrl: "https://status.cloudways.com", + provider: "atlassian-statuspage", + industry: ["cloud-providers"], + description: "Managed cloud hosting platform", + apiConfig: { type: "atlassian" }, + }, + { + slug: "coinbase", + name: "Coinbase", + url: "https://www.coinbase.com", + statusPageUrl: "https://status.coinbase.com", + provider: "atlassian-statuspage", + industry: ["fintech"], + description: "Cryptocurrency exchange platform", + apiConfig: { type: "atlassian" }, + }, + { + slug: "coursera", + name: "Coursera", + url: "https://www.coursera.org", + statusPageUrl: "https://status.coursera.org", + provider: "atlassian-statuspage", + industry: ["saas"], + description: + "Online learning platform with courses from universities and companies", + apiConfig: { type: "atlassian" }, + }, + { + slug: "crowdin", + name: "Crowdin", + url: "https://crowdin.com", + statusPageUrl: "https://status.crowdin.com", + provider: "atlassian-statuspage", + industry: ["saas"], + description: "Localization management platform", + apiConfig: { type: "atlassian" }, + }, + { + slug: "dropbox", + name: "Dropbox", + url: "https://www.dropbox.com", + statusPageUrl: "https://status.dropbox.com", + provider: "atlassian-statuspage", + industry: ["saas"], + description: "Cloud file storage and collaboration", + apiConfig: { type: "atlassian" }, + }, + { + slug: "epic-games", + name: "Epic Games", + url: "https://www.epicgames.com", + statusPageUrl: "https://status.epicgames.com", + provider: "atlassian-statuspage", + industry: ["saas"], + description: + "Game developer and publisher, maker of Fortnite and Unreal Engine", + apiConfig: { type: "atlassian" }, + }, + { + slug: "figma", + name: "Figma", + url: "https://www.figma.com", + statusPageUrl: "https://status.figma.com", + provider: "atlassian-statuspage", + industry: ["saas", "development-tools"], + description: "Collaborative interface design tool", + apiConfig: { type: "atlassian" }, + }, + { + slug: "fivetran", + name: "Fivetran", + url: "https://www.fivetran.com", + statusPageUrl: "https://status.fivetran.com", + provider: "atlassian-statuspage", + industry: ["databases", "development-tools"], + description: "Automated data integration and ELT pipelines", + apiConfig: { type: "atlassian" }, + }, + { + slug: "grammarly", + name: "Grammarly", + url: "https://www.grammarly.com", + statusPageUrl: "https://status.grammarly.com", + provider: "atlassian-statuspage", + industry: ["saas"], + description: "AI writing assistant for grammar and style", + apiConfig: { type: "atlassian" }, + }, + { + slug: "greenhouse", + name: "Greenhouse", + url: "https://www.greenhouse.io", + statusPageUrl: "https://status.greenhouse.io", + provider: "atlassian-statuspage", + industry: ["saas"], + description: "Recruiting and applicant tracking platform", + apiConfig: { type: "atlassian" }, + }, + { + slug: "hostinger", + name: "Hostinger", + url: "https://www.hostinger.com", + statusPageUrl: "https://statuspage.hostinger.com", + provider: "atlassian-statuspage", + industry: ["cloud-providers"], + description: "Web hosting and domain provider", + apiConfig: { type: "atlassian" }, + }, + { + slug: "hubspot", + name: "HubSpot", + url: "https://www.hubspot.com", + statusPageUrl: "https://status.hubspot.com", + provider: "atlassian-statuspage", + industry: ["saas"], + description: "CRM, marketing, sales, and service platform", + apiConfig: { type: "atlassian" }, + }, + { + slug: "jamf", + name: "Jamf", + url: "https://www.jamf.com", + statusPageUrl: "https://status.jamf.com", + provider: "atlassian-statuspage", + industry: ["security", "saas"], + description: "Apple device management for enterprise", + apiConfig: { type: "atlassian" }, + }, + { + slug: "kinsta", + name: "Kinsta", + url: "https://kinsta.com", + statusPageUrl: "https://status.kinsta.com", + provider: "atlassian-statuspage", + industry: ["cloud-providers"], + description: "Managed WordPress and application hosting", + apiConfig: { type: "atlassian" }, + }, + { + slug: "lastpass", + name: "LastPass", + url: "https://www.lastpass.com", + statusPageUrl: "https://status.lastpass.com", + provider: "atlassian-statuspage", + industry: ["security", "saas"], + description: "Password manager and identity service", + apiConfig: { type: "atlassian" }, + }, + { + slug: "launchdarkly", + name: "LaunchDarkly", + url: "https://launchdarkly.com", + statusPageUrl: "https://status.launchdarkly.com", + provider: "atlassian-statuspage", + industry: ["development-tools"], + description: "Feature flag and experimentation platform", + apiConfig: { type: "atlassian" }, + }, + { + slug: "lever", + name: "Lever", + url: "https://www.lever.co", + statusPageUrl: "https://status.lever.co", + provider: "atlassian-statuspage", + industry: ["saas"], + description: "Talent acquisition and applicant tracking suite", + apiConfig: { type: "atlassian" }, + }, + { + slug: "linear", + name: "Linear", + url: "https://linear.app", + statusPageUrl: "https://linearstatus.com", + provider: "atlassian-statuspage", + industry: ["saas", "development-tools"], + description: "Issue tracking and project management for software teams", + apiConfig: { type: "atlassian" }, + }, + { + slug: "linode", + name: "Linode", + url: "https://www.linode.com", + statusPageUrl: "https://status.linode.com", + provider: "atlassian-statuspage", + industry: ["cloud-providers"], + description: "Cloud computing and Linux virtual servers", + apiConfig: { type: "atlassian" }, + }, + { + slug: "loom", + name: "Loom", + url: "https://www.loom.com", + statusPageUrl: "https://status.loom.com", + provider: "atlassian-statuspage", + industry: ["communication"], + description: "Async video messaging for work", + apiConfig: { type: "atlassian" }, + }, + { + slug: "mezmo", + name: "Mezmo", + url: "https://www.mezmo.com", + statusPageUrl: "https://status.mezmo.com", + provider: "atlassian-statuspage", + industry: ["monitoring"], + description: "Observability pipeline for logs and telemetry", + apiConfig: { type: "atlassian" }, + }, + { + slug: "mixpanel", + name: "Mixpanel", + url: "https://mixpanel.com", + statusPageUrl: "https://status.mixpanel.com", + provider: "atlassian-statuspage", + industry: ["monitoring"], + description: "Product analytics for user behavior", + apiConfig: { type: "atlassian" }, + }, + { + slug: "netlify", + name: "Netlify", + url: "https://www.netlify.com", + statusPageUrl: "https://www.netlifystatus.com", + provider: "atlassian-statuspage", + industry: ["cloud-providers", "development-tools"], + description: "Web platform for building and deploying modern sites", + apiConfig: { type: "atlassian" }, + }, + + { + slug: "postman", + name: "Postman", + url: "https://www.postman.com", + statusPageUrl: "https://status.postman.com", + provider: "atlassian-statuspage", + industry: ["development-tools"], + description: "API development and testing platform", + apiConfig: { type: "atlassian" }, + }, + { + slug: "render", + name: "Render", + url: "https://render.com", + statusPageUrl: "https://status.render.com", + provider: "atlassian-statuspage", + industry: ["cloud-providers"], + description: "Cloud platform for hosting web apps and services", + apiConfig: { type: "atlassian" }, + }, + { + slug: "scaleway", + name: "Scaleway", + url: "https://www.scaleway.com", + statusPageUrl: "https://status.scaleway.com", + provider: "atlassian-statuspage", + industry: ["cloud-providers"], + description: "European cloud infrastructure provider", + apiConfig: { type: "atlassian" }, + }, + { + slug: "segment", + name: "Segment", + url: "https://segment.com", + statusPageUrl: "https://status.segment.com", + provider: "atlassian-statuspage", + industry: ["development-tools"], + description: "Customer data platform and event routing", + apiConfig: { type: "atlassian" }, + }, + { + slug: "sendgrid", + name: "SendGrid", + url: "https://sendgrid.com", + statusPageUrl: "https://status.sendgrid.com", + provider: "atlassian-statuspage", + industry: ["communication"], + description: "Transactional and marketing email API", + apiConfig: { type: "atlassian" }, + }, + { + slug: "sentry", + name: "Sentry", + url: "https://sentry.io", + statusPageUrl: "https://status.sentry.io", + provider: "atlassian-statuspage", + industry: ["monitoring", "development-tools"], + description: "Application error monitoring and performance tracing", + apiConfig: { type: "atlassian" }, + }, + { + slug: "snowflake", + name: "Snowflake", + url: "https://www.snowflake.com", + statusPageUrl: "https://status.snowflake.com", + provider: "atlassian-statuspage", + industry: ["databases"], + description: "Cloud data platform for analytics and warehousing", + apiConfig: { type: "atlassian" }, + }, + { + slug: "snyk", + name: "Snyk", + url: "https://snyk.io", + statusPageUrl: "https://status.snyk.io", + provider: "atlassian-statuspage", + industry: ["security", "development-tools"], + description: + "Developer security platform for code, dependencies, and containers", + apiConfig: { type: "atlassian" }, + }, + { + slug: "squarespace", + name: "Squarespace", + url: "https://www.squarespace.com", + statusPageUrl: "https://status.squarespace.com", + provider: "atlassian-statuspage", + industry: ["saas", "e-commerce"], + description: "Website builder and online store platform", + apiConfig: { type: "atlassian" }, + }, + { + slug: "sumo-logic", + name: "Sumo Logic", + url: "https://www.sumologic.com", + statusPageUrl: "https://status.sumologic.com", + provider: "atlassian-statuspage", + industry: ["monitoring"], + description: "Cloud log management and security analytics", + apiConfig: { type: "atlassian" }, + }, + { + slug: "teamviewer", + name: "TeamViewer", + url: "https://www.teamviewer.com", + statusPageUrl: "https://status.teamviewer.com", + provider: "atlassian-statuspage", + industry: ["saas"], + description: "Remote desktop and remote support software", + apiConfig: { type: "atlassian" }, + }, + { + slug: "trello", + name: "Trello", + url: "https://trello.com", + statusPageUrl: "https://trello.status.atlassian.com", + provider: "atlassian-statuspage", + industry: ["saas"], + description: "Kanban-style project management tool", + apiConfig: { type: "atlassian" }, + }, + { + slug: "udemy", + name: "Udemy", + url: "https://www.udemy.com", + statusPageUrl: "https://status.udemy.com", + provider: "atlassian-statuspage", + industry: ["saas"], + description: "Online learning and teaching marketplace", + apiConfig: { type: "atlassian" }, + }, + { + slug: "upcloud", + name: "UpCloud", + url: "https://upcloud.com", + statusPageUrl: "https://status.upcloud.com", + provider: "atlassian-statuspage", + industry: ["cloud-providers"], + description: "European cloud server provider", + apiConfig: { type: "atlassian" }, + }, + { + slug: "vimeo", + name: "Vimeo", + url: "https://vimeo.com", + statusPageUrl: "https://status.vimeo.com", + provider: "atlassian-statuspage", + industry: ["communication"], + description: "Video hosting, sharing, and streaming platform", + apiConfig: { type: "atlassian" }, + }, + { + slug: "webflow", + name: "Webflow", + url: "https://webflow.com", + statusPageUrl: "https://status.webflow.com", + provider: "atlassian-statuspage", + industry: ["saas", "development-tools"], + description: "Visual web design and CMS platform", + apiConfig: { type: "atlassian" }, + }, + { + slug: "wix", + name: "Wix", + url: "https://www.wix.com", + statusPageUrl: "https://status.wix.com", + provider: "atlassian-statuspage", + industry: ["saas", "e-commerce"], + description: "Website builder and business platform", + apiConfig: { type: "atlassian" }, + }, ]; diff --git a/scripts/verify-statuspages.ts b/scripts/verify-statuspages.ts new file mode 100644 index 0000000000..59c229b867 --- /dev/null +++ b/scripts/verify-statuspages.ts @@ -0,0 +1,272 @@ +type Candidate = { name: string; statusPageUrl: string }; + +const CANDIDATES: Candidate[] = [ + { name: "Mailchimp", statusPageUrl: "https://status.mailchimp.com/" }, + { name: "Notion", statusPageUrl: "https://status.notion.so/" }, + { name: "Intercom", statusPageUrl: "https://status.intercom.com/" }, + { name: "Mixpanel", statusPageUrl: "https://status.mixpanel.com/" }, + { name: "Segment", statusPageUrl: "https://status.segment.com/" }, + { name: "Trello", statusPageUrl: "https://trello.status.atlassian.com/" }, + { name: "Coinbase", statusPageUrl: "https://status.coinbase.com/" }, + { name: "Asana", statusPageUrl: "https://trust.asana.com/" }, + { name: "Squarespace", statusPageUrl: "https://status.squarespace.com/" }, + { name: "Wix", statusPageUrl: "https://status.wix.com/" }, + { name: "Shopify", statusPageUrl: "https://www.shopifystatus.com/" }, + { name: "Auth0", statusPageUrl: "https://status.auth0.com/" }, + { name: "Okta", statusPageUrl: "https://status.okta.com/" }, + { name: "MongoDB", statusPageUrl: "https://status.mongodb.com/" }, + { name: "Sentry", statusPageUrl: "https://status.sentry.io/" }, + { name: "CircleCI", statusPageUrl: "https://status.circleci.com/" }, + { + name: "Bitbucket", + statusPageUrl: "https://bitbucket.status.atlassian.com/", + }, + { name: "Algolia", statusPageUrl: "https://status.algolia.com/" }, + { name: "Snyk", statusPageUrl: "https://status.snyk.io/" }, + { name: "Looker", statusPageUrl: "https://status.looker.com/" }, + { name: "DocuSign", statusPageUrl: "https://trust.docusign.com/" }, + { name: "Webflow", statusPageUrl: "https://status.webflow.com/" }, + { name: "Buffer", statusPageUrl: "https://status.buffer.com/" }, + { name: "Loom", statusPageUrl: "https://status.loom.com/" }, + { name: "ClickUp", statusPageUrl: "https://status.clickup.com/" }, + { name: "Calendly", statusPageUrl: "https://status.calendly.com/" }, + { name: "Zapier", statusPageUrl: "https://status.zapier.com/" }, + { name: "SendGrid", statusPageUrl: "https://status.sendgrid.com/" }, + { name: "Snowflake", statusPageUrl: "https://status.snowflake.com/" }, + { name: "Databricks", statusPageUrl: "https://status.databricks.com/" }, + { name: "Airtable", statusPageUrl: "https://status.airtable.com/" }, + { name: "Postman", statusPageUrl: "https://status.postman.com/" }, + { name: "LaunchDarkly", statusPageUrl: "https://status.launchdarkly.com/" }, + { name: "Fivetran", statusPageUrl: "https://status.fivetran.com/" }, + { name: "Bitly", statusPageUrl: "https://status.bitly.com/" }, + { name: "Greenhouse", statusPageUrl: "https://status.greenhouse.io/" }, + { name: "Lever", statusPageUrl: "https://status.lever.co/" }, + { name: "Miro", statusPageUrl: "https://status.miro.com/" }, + { name: "Figma", statusPageUrl: "https://status.figma.com/" }, + { name: "Vimeo", statusPageUrl: "https://status.vimeo.com/" }, + { name: "LastPass", statusPageUrl: "https://status.lastpass.com/" }, + { + name: "Apollo GraphQL", + statusPageUrl: "https://status.apollographql.com/", + }, + { name: "Render", statusPageUrl: "https://status.render.com/" }, + { name: "Cloudinary", statusPageUrl: "https://status.cloudinary.com/" }, + { name: "Coursera", statusPageUrl: "https://status.coursera.org/" }, + { name: "Jamf", statusPageUrl: "https://status.jamf.com/" }, + { name: "Mezmo", statusPageUrl: "https://status.mezmo.com/" }, + { name: "GitLab", statusPageUrl: "https://status.gitlab.com/" }, + { name: "Sumo Logic", statusPageUrl: "https://status.sumologic.com/" }, + { name: "Replit", statusPageUrl: "https://status.replit.com/" }, + { name: "Vercel", statusPageUrl: "https://www.vercel-status.com/" }, + { name: "Atlassian", statusPageUrl: "https://status.atlassian.com/" }, + { name: "GitHub", statusPageUrl: "https://www.githubstatus.com/" }, + { name: "Cloudflare", statusPageUrl: "https://www.cloudflarestatus.com/" }, + { name: "Discord", statusPageUrl: "https://discordstatus.com/" }, + { name: "Reddit", statusPageUrl: "https://www.redditstatus.com/" }, + { name: "Slack", statusPageUrl: "https://status.slack.com/" }, + { name: "Stripe", statusPageUrl: "https://status.stripe.com/" }, + { name: "Twilio", statusPageUrl: "https://status.twilio.com/" }, + { name: "Dropbox", statusPageUrl: "https://status.dropbox.com/" }, + { name: "DigitalOcean", statusPageUrl: "https://status.digitalocean.com/" }, + { name: "Heroku", statusPageUrl: "https://status.heroku.com/" }, + { name: "Zoom", statusPageUrl: "https://status.zoom.us/" }, + { name: "New Relic", statusPageUrl: "https://status.newrelic.com/" }, + { name: "PagerDuty", statusPageUrl: "https://status.pagerduty.com/" }, + { name: "Datadog", statusPageUrl: "https://status.datadoghq.com/" }, + { name: "Adobe", statusPageUrl: "https://status.adobe.com/" }, + { name: "Box", statusPageUrl: "https://status.box.com/" }, + { name: "Linode", statusPageUrl: "https://status.linode.com/" }, + { name: "Netlify", statusPageUrl: "https://www.netlifystatus.com/" }, + { name: "Fastly", statusPageUrl: "https://status.fastly.com/" }, + { name: "Epic Games", statusPageUrl: "https://status.epicgames.com/" }, + { + name: "Sony PlayStation", + statusPageUrl: "https://status.playstation.com/", + }, + { name: "Zendesk", statusPageUrl: "https://status.zendesk.com/" }, + { name: "HubSpot", statusPageUrl: "https://status.hubspot.com/" }, + { name: "Hetzner", statusPageUrl: "https://status.hetzner.com/" }, + { name: "Scaleway", statusPageUrl: "https://status.scaleway.com/" }, + { name: "Vultr", statusPageUrl: "https://status.vultr.com/" }, + { name: "Bluesky", statusPageUrl: "https://status.bsky.app/" }, + { name: "OpenAI", statusPageUrl: "https://status.openai.com/" }, + { name: "PayPal", statusPageUrl: "https://www.paypal-status.com/" }, + { name: "Pinterest", statusPageUrl: "https://www.pintereststatus.com/" }, + { name: "Rackspace", statusPageUrl: "https://status.rackspace.com/" }, + { name: "Tailscale", statusPageUrl: "https://status.tailscale.com/" }, + { name: "Salesforce", statusPageUrl: "https://status.salesforce.com/" }, + { name: "Udemy", statusPageUrl: "https://status.udemy.com/" }, + { name: "Grammarly", statusPageUrl: "https://status.grammarly.com/" }, + { name: "Crowdin", statusPageUrl: "https://status.crowdin.com/" }, + { name: "BambooHR", statusPageUrl: "https://status.bamboohr.com/" }, + { name: "Amplitude", statusPageUrl: "https://status.amplitude.com/" }, + { name: "AppsFlyer", statusPageUrl: "https://status.appsflyer.com/" }, + { name: "Linear", statusPageUrl: "https://linearstatus.com/" }, + { name: "Taskade", statusPageUrl: "https://status.taskade.com/" }, + { name: "Cloudways", statusPageUrl: "https://status.cloudways.com/" }, + { name: "UpCloud", statusPageUrl: "https://status.upcloud.com/" }, + { name: "Kinsta", statusPageUrl: "https://status.kinsta.com/" }, + { name: "Hostinger", statusPageUrl: "https://statuspage.hostinger.com/" }, + { name: "1Password", statusPageUrl: "https://status.1password.com/" }, + { name: "TeamViewer", statusPageUrl: "https://status.teamviewer.com/" }, + { name: "Twitter/X API", statusPageUrl: "https://api.twitterstat.us/" }, +]; + +type Verdict = + | { kind: "atlassian"; pageId: string; pageUrl: string } + | { kind: "not-atlassian"; reason: string; httpStatus?: number }; + +type Result = { candidate: Candidate; verdict: Verdict }; + +const UA = "openstatus-seed-verifier/1.0"; +const TIMEOUT_MS = 10_000; +const CONCURRENCY = 8; + +function normalizeBase(url: string): string { + return url.replace(/\/+$/, ""); +} + +type AtlassianPage = { id?: unknown; url?: unknown }; +type AtlassianSummary = { page?: AtlassianPage }; + +function isAtlassianSummary(value: unknown): value is AtlassianSummary { + return typeof value === "object" && value !== null && "page" in value; +} + +async function probe(c: Candidate): Promise { + const base = normalizeBase(c.statusPageUrl); + const url = `${base}/api/v2/summary.json`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + + try { + const res = await fetch(url, { + headers: { "user-agent": UA, accept: "application/json" }, + redirect: "follow", + signal: controller.signal, + }); + + if (!res.ok) { + return { + candidate: c, + verdict: { + kind: "not-atlassian", + reason: `HTTP ${res.status}`, + httpStatus: res.status, + }, + }; + } + + const ctype = res.headers.get("content-type") ?? ""; + if (!ctype.includes("application/json")) { + return { + candidate: c, + verdict: { + kind: "not-atlassian", + reason: `non-json content-type: ${ctype}`, + }, + }; + } + + const body: unknown = await res.json(); + if (!isAtlassianSummary(body)) { + return { + candidate: c, + verdict: { kind: "not-atlassian", reason: "no page field" }, + }; + } + + const pageId = body.page?.id; + const pageUrl = body.page?.url; + if (typeof pageId !== "string" || typeof pageUrl !== "string") { + return { + candidate: c, + verdict: { + kind: "not-atlassian", + reason: "page.id / page.url missing", + }, + }; + } + + return { candidate: c, verdict: { kind: "atlassian", pageId, pageUrl } }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + candidate: c, + verdict: { kind: "not-atlassian", reason: `error: ${msg}` }, + }; + } finally { + clearTimeout(timer); + } +} + +async function runPool( + items: T[], + limit: number, + worker: (item: T) => Promise, +): Promise { + const results: R[] = new Array(items.length); + let cursor = 0; + + async function run(): Promise { + while (true) { + const idx = cursor++; + if (idx >= items.length) return; + const item = items[idx]; + if (item === undefined) return; + results[idx] = await worker(item); + } + } + + const workers = Array.from({ length: Math.min(limit, items.length) }, () => + run(), + ); + await Promise.all(workers); + return results; +} + +async function main(): Promise { + const results = await runPool(CANDIDATES, CONCURRENCY, probe); + + const atlassian: Result[] = []; + const notAtlassian: Result[] = []; + for (const r of results) { + (r.verdict.kind === "atlassian" ? atlassian : notAtlassian).push(r); + } + + console.log("# verified-atlassian"); + console.log("name,statusPageUrl,pageId,pageUrl"); + for (const r of atlassian) { + if (r.verdict.kind !== "atlassian") continue; + console.log( + [ + r.candidate.name, + r.candidate.statusPageUrl, + r.verdict.pageId, + r.verdict.pageUrl, + ].join(","), + ); + } + + console.log(""); + console.log("# needs-review"); + console.log("name,statusPageUrl,reason,httpStatus"); + for (const r of notAtlassian) { + if (r.verdict.kind !== "not-atlassian") continue; + console.log( + [ + r.candidate.name, + r.candidate.statusPageUrl, + r.verdict.reason, + r.verdict.httpStatus ?? "", + ].join(","), + ); + } + + console.log(""); + console.log( + `# totals: atlassian=${atlassian.length} other=${notAtlassian.length}`, + ); +} + +await main();