diff --git a/.github/ISSUE_TEMPLATE/integration.yml b/.github/ISSUE_TEMPLATE/integration.yml index 549808df3..08f78b9b5 100644 --- a/.github/ISSUE_TEMPLATE/integration.yml +++ b/.github/ISSUE_TEMPLATE/integration.yml @@ -19,7 +19,10 @@ body: - type: textarea attributes: label: Additional information - description: Add any other information related to the integration. + description: | + Add any other information related to the integration. + + After your integration is merged to `dev`, a bot PR updates [CODEOWNERS](https://github.com/homarr-labs/homarr/blob/dev/CODEOWNERS) so you are requested on future PRs touching your folders. Run `pnpm scripts:generate-codeowners` locally for faster updates. Add email mappings in `scripts/codeowners-overrides.json` if the generator cannot resolve your GitHub user. - type: dropdown attributes: label: Public API available? diff --git a/.github/ISSUE_TEMPLATE/widget.yml b/.github/ISSUE_TEMPLATE/widget.yml index aa1d0f6a1..62a0bc14f 100644 --- a/.github/ISSUE_TEMPLATE/widget.yml +++ b/.github/ISSUE_TEMPLATE/widget.yml @@ -19,7 +19,10 @@ body: - type: textarea attributes: label: Additional information - description: Add any other information related to the widget. + description: | + Add any other information related to the widget. + + After your widget is merged to `dev`, a bot PR updates [CODEOWNERS](https://github.com/homarr-labs/homarr/blob/dev/CODEOWNERS) so you are requested on future PRs touching your folders. Run `pnpm scripts:generate-codeowners` locally for faster updates. Add email mappings in `scripts/codeowners-overrides.json` if the generator cannot resolve your GitHub user. - type: dropdown attributes: label: Are you willing to contribute this yourself? diff --git a/.github/workflows/update-codeowners.yml b/.github/workflows/update-codeowners.yml new file mode 100644 index 000000000..e98e9e04e --- /dev/null +++ b/.github/workflows/update-codeowners.yml @@ -0,0 +1,82 @@ +name: Update CODEOWNERS + +on: + workflow_dispatch: {} + push: + paths: + - packages/widgets/src/** + - packages/integrations/src/** + - packages/api/src/router/widgets/** + - packages/cron-jobs/src/jobs/** + - scripts/generate-codeowners.mts + - scripts/codeowners-overrides.json + branches: + - dev + +permissions: + pull-requests: write + +jobs: + update-codeowners: + concurrency: + group: update-codeowners + cancel-in-progress: false + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Obtain token + id: obtainToken + uses: actions/create-github-app-token@v3 + with: + private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }} + client-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }} + permission-contents: write + permission-pull-requests: write + + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + env: + GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} + + - uses: pnpm/action-setup@v6 + + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate CODEOWNERS + env: + GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} + run: pnpm scripts:generate-codeowners + + - name: Create Pull Request + id: create-pull-request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ steps.obtainToken.outputs.token }} + branch: update-codeowners + base: dev + title: "chore: update widget/integration CODEOWNERS" + delete-branch: true + commit-message: "chore: update widget/integration CODEOWNERS" + body: | + This PR updates per-widget and per-integration CODEOWNERS entries from git history. + + Unmapped authors can be added in `scripts/codeowners-overrides.json`. + + - name: Install GitHub CLI + run: sudo apt-get install -y gh + + - name: Enable auto-merge + if: steps.create-pull-request.outputs.pull-request-number != '' + env: + GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} + run: | + gh pr merge ${{ steps.create-pull-request.outputs.pull-request-number }} --auto --squash diff --git a/CODEOWNERS b/CODEOWNERS index b798cfada..e8f9a0092 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -11,3 +11,86 @@ pnpm-lock.yaml pnpm-workspace.yaml Dockerfile docker-compose.yml + +# BEGIN GENERATED widget/integration owners +/packages/api/src/router/widgets/anchor-notes.ts /packages/integrations/src/anchor/ /packages/widgets/src/anchor-note/ @omariqbal +/packages/api/src/router/widgets/app.ts /packages/widgets/src/app/ @manuel-rw +/packages/api/src/router/widgets/calendar.ts /packages/widgets/src/calendar/ @manuel-rw +/packages/api/src/router/widgets/coolify.ts /packages/integrations/src/coolify/ /packages/widgets/src/coolify/ @Rhiz3K +/packages/api/src/router/widgets/dns-hole.ts /packages/cron-jobs/src/jobs/integrations/dns-hole.ts /packages/widgets/src/dns-hole/controls/ @manuel-rw +/packages/api/src/router/widgets/dns-hole.ts /packages/cron-jobs/src/jobs/integrations/dns-hole.ts /packages/widgets/src/dns-hole/summary/ @manuel-rw +/packages/api/src/router/widgets/downloads.ts /packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/widgets/src/downloads/ @manuel-rw +/packages/api/src/router/widgets/firewall.ts /packages/cron-jobs/src/jobs/integrations/firewall.ts /packages/widgets/src/firewall/ @Oupsman +/packages/api/src/router/widgets/health-monitoring.ts /packages/cron-jobs/src/jobs/integrations/health-monitoring.ts /packages/widgets/src/health-monitoring/ @dgshue +/packages/api/src/router/widgets/immich.ts /packages/integrations/src/immich/ /packages/widgets/src/immich/album-carousel/ @manuel-rw +/packages/api/src/router/widgets/immich.ts /packages/integrations/src/immich/ /packages/widgets/src/immich/server-stats/ @manuel-rw +/packages/api/src/router/widgets/indexer-manager.ts /packages/cron-jobs/src/jobs/integrations/indexer-manager.ts /packages/widgets/src/indexer-manager/ @manuel-rw +/packages/api/src/router/widgets/media-release.ts /packages/widgets/src/media-releases/ @manuel-rw +/packages/api/src/router/widgets/media-requests.ts /packages/cron-jobs/src/jobs/integrations/media-requests.ts /packages/widgets/src/media-requests/list/ @manuel-rw +/packages/api/src/router/widgets/media-requests.ts /packages/cron-jobs/src/jobs/integrations/media-requests.ts /packages/widgets/src/media-requests/stats/ @manuel-rw +/packages/api/src/router/widgets/media-server.ts /packages/cron-jobs/src/jobs/integrations/media-server.ts /packages/widgets/src/media-server/ @manuel-rw +/packages/api/src/router/widgets/media-transcoding.ts /packages/cron-jobs/src/jobs/integrations/media-transcoding.ts /packages/integrations/src/media-transcoding/ /packages/widgets/src/media-transcoding/ @manuel-rw +/packages/api/src/router/widgets/minecraft.ts /packages/widgets/src/minecraft/server-status/ @manuel-rw +/packages/api/src/router/widgets/network-controller.ts /packages/cron-jobs/src/jobs/integrations/network-controller.ts /packages/widgets/src/network-controller/network-status/ @pitschi +/packages/api/src/router/widgets/network-controller.ts /packages/cron-jobs/src/jobs/integrations/network-controller.ts /packages/widgets/src/network-controller/summary/ @pitschi +/packages/api/src/router/widgets/notebook.ts /packages/widgets/src/notebook/ @manuel-rw +/packages/api/src/router/widgets/notifications.ts /packages/cron-jobs/src/jobs/integrations/notifications.ts /packages/widgets/src/notifications/ @itzTheMeow +/packages/api/src/router/widgets/releases.ts /packages/widgets/src/releases/ @Aandree5 +/packages/api/src/router/widgets/rssFeed.ts /packages/widgets/src/rssFeed/ @manuel-rw +/packages/api/src/router/widgets/smart-home.ts /packages/cron-jobs/src/jobs/integrations/home-assistant.ts /packages/widgets/src/smart-home/entity-state/ @manuel-rw +/packages/api/src/router/widgets/smart-home.ts /packages/cron-jobs/src/jobs/integrations/home-assistant.ts /packages/widgets/src/smart-home/execute-automation/ @manuel-rw +/packages/api/src/router/widgets/speedtest-tracker.ts /packages/integrations/src/speedtest-tracker/ /packages/widgets/src/speedtest-tracker/ @kylegrieder +/packages/api/src/router/widgets/stocks.ts /packages/widgets/src/stocks/ @jackwellerreal +/packages/api/src/router/widgets/timetable.ts /packages/cron-jobs/src/jobs/integrations/timetable.ts /packages/widgets/src/timetable/ @manuel-rw +/packages/api/src/router/widgets/tracearr.ts /packages/cron-jobs/src/jobs/integrations/tracearr.ts /packages/integrations/src/tracearr/ /packages/widgets/src/tracearr/ @StrandedTurtle +/packages/api/src/router/widgets/umami.ts /packages/cron-jobs/src/jobs/integrations/umami.ts /packages/integrations/src/umami/ /packages/widgets/src/umami/ @febrile42 +/packages/api/src/router/widgets/weather.ts /packages/cron-jobs/src/jobs/weather.ts /packages/widgets/src/weather/ @manuel-rw +/packages/cron-jobs/src/jobs/docker.ts /packages/widgets/src/docker/ @ashtonbatty +/packages/cron-jobs/src/jobs/integrations/dns-hole.ts /packages/integrations/src/pi-hole/ @manuel-rw +/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/aria2/ @manuel-rw +/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/deluge/ @manuel-rw +/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/nzbget/ @manuel-rw +/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/qbittorrent/ @manuel-rw +/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/sabnzbd/ @manuel-rw +/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/slskd/ @manuel-rw +/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/transmission/ @manuel-rw +/packages/cron-jobs/src/jobs/integrations/home-assistant.ts /packages/integrations/src/homeassistant/ @manuel-rw +/packages/cron-jobs/src/jobs/integrations/media-organizer.ts /packages/integrations/src/media-organizer/lidarr/ @NicolasNewman +/packages/cron-jobs/src/jobs/integrations/media-organizer.ts /packages/integrations/src/media-organizer/radarr/ @manuel-rw +/packages/cron-jobs/src/jobs/integrations/media-organizer.ts /packages/integrations/src/media-organizer/readarr/ @manuel-rw +/packages/cron-jobs/src/jobs/integrations/media-organizer.ts /packages/integrations/src/media-organizer/sonarr/ @manuel-rw +/packages/integrations/src/adguard-home/ @manuel-rw +/packages/integrations/src/codeberg/ @Aandree5 +/packages/integrations/src/dashdot/ @dgshue +/packages/integrations/src/docker-hub/ @Aandree5 +/packages/integrations/src/emby/ @manuel-rw +/packages/integrations/src/github-container-registry/ @manuel-rw +/packages/integrations/src/github/ @manuel-rw +/packages/integrations/src/gitlab/ @Aandree5 +/packages/integrations/src/glances/ @dgshue +/packages/integrations/src/ical/ @NicolasNewman +/packages/integrations/src/jellyfin/ @manuel-rw +/packages/integrations/src/jellyseerr/ @manuel-rw +/packages/integrations/src/linuxserverio/ @Aandree5 +/packages/integrations/src/nextcloud/ @manuel-rw +/packages/integrations/src/npm/ @Aandree5 +/packages/integrations/src/ntfy/ @manuel-rw +/packages/integrations/src/openmediavault/ @dgshue +/packages/integrations/src/opnsense/ @manuel-rw +/packages/integrations/src/overseerr/ @manuel-rw +/packages/integrations/src/plex/ @manuel-rw +/packages/integrations/src/prowlarr/ @manuel-rw +/packages/integrations/src/proxmox/ @manuel-rw +/packages/integrations/src/quay/ @Aandree5 +/packages/integrations/src/search-ch/ @manuel-rw +/packages/integrations/src/seerr/ @manuel-rw +/packages/integrations/src/truenas/ @dgshue +/packages/integrations/src/unifi-controller/ @manuel-rw +/packages/integrations/src/unraid/ @dgshue +/packages/widgets/src/bookmarks/ @manuel-rw +/packages/widgets/src/clock/ @manuel-rw +/packages/widgets/src/iframe/ @manuel-rw +/packages/widgets/src/system-disks/ @manuel-rw +/packages/widgets/src/system-resources/ @kylegrieder +/packages/widgets/src/video/ @manuel-rw +# END GENERATED widget/integration owners diff --git a/package.json b/package.json index 7c27c59b2..3ad4870a1 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "release": "semantic-release", "scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts", "scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts", + "scripts:generate-codeowners": "tsx ./scripts/generate-codeowners.mts", "start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"", "test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ", "test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e", diff --git a/scripts/codeowners-overrides.json b/scripts/codeowners-overrides.json new file mode 100644 index 000000000..732b8f35b --- /dev/null +++ b/scripts/codeowners-overrides.json @@ -0,0 +1,11 @@ +{ + "meierschlumpf@gmail.com": "manuel-rw", + "thomas@ajnart.fr": "ajnart", + "omariqbal97@gmail.com": "omariqbal", + "kyle.grieder@gmail.com": "kylegrieder", + "oupsman@oupsman.fr": "Oupsman", + "pitschi@posteo.de": "pitschi", + "github@xela.codes": "itzTheMeow", + "febrile42@gmail.com": "febrile42", + "nanewman01@gmail.com": "NicolasNewman" +} diff --git a/scripts/generate-codeowners.mts b/scripts/generate-codeowners.mts new file mode 100644 index 000000000..366a1f8d8 --- /dev/null +++ b/scripts/generate-codeowners.mts @@ -0,0 +1,309 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readdirSync, statSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; +import { join, relative } from "node:path"; + +const REPO_ROOT = join(import.meta.dirname, ".."); +const CODEOWNERS_PATH = join(REPO_ROOT, "CODEOWNERS"); +const OVERRIDES_PATH = join(REPO_ROOT, "scripts/codeowners-overrides.json"); + +const BEGIN_MARKER = "# BEGIN GENERATED widget/integration owners"; +const END_MARKER = "# END GENERATED widget/integration owners"; + +const WIDGET_SKIP = new Set(["_inputs", "errors", "modals", "test"]); +const INTEGRATION_SKIP = new Set(["base", "interfaces", "mock"]); + +const BOT_SUBSTRINGS = ["[bot]", "renovate", "homarr-update", "dependabot", "github-actions"]; + +const WIDGET_ROUTER_ALIASES: Record = { + "anchor-note": "anchor-notes", + "media-releases": "media-release", + rssFeed: "rssFeed", + stocks: "stocks", +}; + +const INTEGRATION_CRON_ALIASES: Record = { + homeassistant: "home-assistant", + "download-client": "downloads", + "media-organizer": "media-organizer", + "pi-hole": "dns-hole", + "smart-home": "home-assistant", +}; + +const WIDGET_TO_INTEGRATION_DIR: Record = { + "anchor-note": "anchor", +}; + +const NESTED_WIDGET_ROUTER_PARENT: Record = { + "dns-hole": "dns-hole", + immich: "immich", + "media-requests": "media-requests", + "network-controller": "network-controller", + minecraft: "minecraft", + "smart-home": "smart-home", +}; + +type Overrides = Record; + +const isBotEmail = (email: string) => BOT_SUBSTRINGS.some((fragment) => email.toLowerCase().includes(fragment)); + +const gitEmailForPath = (repoPath: string) => { + const strategies = [ + ["log", "--grep=feat", "-i", "--format=%ae", "--reverse", "-1", "--", repoPath], + ["log", "--diff-filter=A", "--format=%ae", "--reverse", "-1", "--", repoPath], + ["log", "--format=%ae", "--reverse", "-1", "--", repoPath], + ] as const; + + for (const args of strategies) { + const result = spawnSync("git", args, { cwd: REPO_ROOT, encoding: "utf8" }); + const email = (result.stdout ?? "").trim(); + if (email && !isBotEmail(email)) return email; + } + return ""; +}; + +const noreplyHandle = (email: string) => { + const match = email.match(/^\d+\+([^@]+)@users\.noreply\.github\.com$/i); + return match?.[1] ?? null; +}; + +const resolveHandle = (email: string, overrides: Overrides) => { + const fromNoreply = noreplyHandle(email); + if (fromNoreply) return fromNoreply; + return overrides[email] ?? null; +}; + +const hasLeafMarker = (dirPath: string) => { + const names = readdirSync(dirPath); + const leafPatterns = ["-integration.ts", "component.tsx", "index.ts", "index.tsx"]; + return names.some((name) => leafPatterns.some((suffix) => name.endsWith(suffix) || name === suffix)); +}; + +const discoverLeafDirs = (base: string, skip: Set) => { + const absoluteBase = join(REPO_ROOT, base); + const leaves: string[] = []; + + for (const name of readdirSync(absoluteBase).sort()) { + const dirPath = join(absoluteBase, name); + if (!statSync(dirPath).isDirectory() || skip.has(name)) continue; + + if (hasLeafMarker(dirPath)) { + leaves.push(join(base, name)); + continue; + } + + const nestedLeaves = readdirSync(dirPath) + .filter((sub) => statSync(join(dirPath, sub)).isDirectory()) + .filter((sub) => hasLeafMarker(join(dirPath, sub))) + .map((sub) => join(base, name, sub)); + + leaves.push(...(nestedLeaves.length > 0 ? nestedLeaves : [join(base, name)])); + } + + return leaves; +}; + +const relFromBase = (fullPath: string, base: string) => relative(join(REPO_ROOT, base), join(REPO_ROOT, fullPath)); + +const widgetRouterKey = (widgetRel: string) => { + const direct = WIDGET_ROUTER_ALIASES[widgetRel]; + if (direct) return direct; + + const segment = widgetRel.includes("/") ? (widgetRel.split("/")[0] ?? widgetRel) : widgetRel; + const parentAlias = NESTED_WIDGET_ROUTER_PARENT[segment]; + const segmentAlias = WIDGET_ROUTER_ALIASES[segment]; + return parentAlias ?? segmentAlias ?? segment; +}; + +const integrationCronKey = (integrationRel: string) => { + const segment = integrationRel.includes("/") ? (integrationRel.split("/")[0] ?? integrationRel) : integrationRel; + return INTEGRATION_CRON_ALIASES[segment] ?? INTEGRATION_CRON_ALIASES[integrationRel] ?? segment; +}; + +const existingFile = (repoPath: string) => { + const full = join(REPO_ROOT, repoPath); + return existsSync(full) ? `/${repoPath}` : null; +}; + +const widgetSatellites = (widgetRel: string) => { + const routerKey = widgetRouterKey(widgetRel); + const cronKey = integrationCronKey(widgetRel); + return [ + existingFile(`packages/api/src/router/widgets/${routerKey}.ts`), + existingFile(`packages/cron-jobs/src/jobs/integrations/${cronKey}.ts`), + existingFile(`packages/cron-jobs/src/jobs/${cronKey}.ts`), + ].filter((path): path is string => path !== null); +}; + +const integrationSatellites = (integrationRel: string) => { + const cronKey = integrationCronKey(integrationRel); + return [ + existingFile(`packages/cron-jobs/src/jobs/integrations/${cronKey}.ts`), + existingFile(`packages/cron-jobs/src/jobs/${cronKey}.ts`), + ].filter((path): path is string => path !== null); +}; + +const dirCodeownersPath = (repoDir: string) => `/${repoDir}/`; + +const integrationRelForWidget = (widgetRel: string) => { + const mapped = WIDGET_TO_INTEGRATION_DIR[widgetRel] ?? WIDGET_TO_INTEGRATION_DIR[widgetRel.split("/")[0] ?? ""]; + if (mapped) return mapped; + + const direct = join("packages/integrations/src", widgetRel); + if (existsSync(join(REPO_ROOT, direct))) return widgetRel; + + const segment = widgetRel.split("/")[0] ?? widgetRel; + const segmentPath = join("packages/integrations/src", segment); + if (existsSync(join(REPO_ROOT, segmentPath))) return segment; + + return null; +}; + +const collectPathsForWidget = (widgetRel: string) => { + const paths = new Set([dirCodeownersPath(`packages/widgets/src/${widgetRel}`), ...widgetSatellites(widgetRel)]); + + const integrationRel = integrationRelForWidget(widgetRel); + if (integrationRel) { + paths.add(dirCodeownersPath(`packages/integrations/src/${integrationRel}`)); + for (const satellite of integrationSatellites(integrationRel)) paths.add(satellite); + } + + return [...paths]; +}; + +const collectPathsForIntegration = (integrationRel: string) => { + const paths = new Set([ + dirCodeownersPath(`packages/integrations/src/${integrationRel}`), + ...integrationSatellites(integrationRel), + ]); + + const widgetPath = join(REPO_ROOT, "packages/widgets/src", integrationRel); + if (existsSync(widgetPath)) { + paths.add(dirCodeownersPath(`packages/widgets/src/${integrationRel}`)); + for (const satellite of widgetSatellites(integrationRel)) paths.add(satellite); + } + + return [...paths]; +}; + +const loadOverrides = async () => JSON.parse(await readFile(OVERRIDES_PATH, "utf8")) as Overrides; + +const resolveGithubUser = async (email: string, overrides: Overrides) => { + const fromOverrides = resolveHandle(email, overrides); + if (fromOverrides) return fromOverrides; + + const token = process.env.GITHUB_TOKEN; + if (!token) return null; + + const response = await fetch(`https://api.github.com/search/users?q=${encodeURIComponent(email)}`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + if (!response.ok) return null; + + const data = (await response.json()) as { items: { login: string }[] }; + return data.items[0]?.login ?? null; +}; + +type OwnerEntry = { primary: string; line: string }; + +const buildOwnerEntries = async (overrides: Overrides) => { + const entries: OwnerEntry[] = []; + const unresolved: { path: string; email: string }[] = []; + const claimedDirs = new Set(); + + const addUnit = async (repoDir: string, collectPaths: (rel: string) => string[]) => { + if (claimedDirs.has(repoDir)) return; + + const rel = + repoDir.startsWith("packages/widgets/") ? + relFromBase(repoDir, "packages/widgets/src") + : relFromBase(repoDir, "packages/integrations/src"); + + const email = gitEmailForPath(`${repoDir}/`); + if (!email) { + unresolved.push({ path: repoDir, email: "" }); + return; + } + + const handle = await resolveGithubUser(email, overrides); + if (!handle) { + unresolved.push({ path: repoDir, email }); + return; + } + + const paths = collectPaths(rel).sort(); + const primary = paths[0] ?? `/${repoDir}/`; + entries.push({ primary, line: `${paths.join(" ")} @${handle}` }); + + claimedDirs.add(repoDir); + for (const path of paths) { + if (!path.endsWith("/")) continue; + claimedDirs.add(path.slice(1, -1)); + } + }; + + for (const widgetDir of discoverLeafDirs("packages/widgets/src", WIDGET_SKIP)) { + await addUnit(widgetDir, collectPathsForWidget); + } + + for (const integrationDir of discoverLeafDirs("packages/integrations/src", INTEGRATION_SKIP)) { + await addUnit(integrationDir, collectPathsForIntegration); + } + + entries.sort((a, b) => a.primary.localeCompare(b.primary)); + return { entries, unresolved }; +}; + +const formatGeneratedBlock = (entries: OwnerEntry[]) => { + const lines = entries.map((entry) => entry.line); + return [BEGIN_MARKER, ...lines, END_MARKER].join("\n"); +}; + +const replaceGeneratedSection = (content: string, generatedBlock: string) => { + const startIndex = content.indexOf(BEGIN_MARKER); + const endIndex = content.indexOf(END_MARKER); + + if (startIndex === -1 || endIndex === -1) { + return `${content.trimEnd()}\n\n${generatedBlock}\n`; + } + + const before = content.slice(0, startIndex).trimEnd(); + const after = content.slice(endIndex + END_MARKER.length).trimStart(); + const middle = generatedBlock; + const chunks = [before, middle, after].filter((chunk) => chunk.length > 0); + return `${chunks.join("\n\n")}\n`; +}; + +const main = async () => { + const overrides = await loadOverrides(); + const { entries, unresolved } = await buildOwnerEntries(overrides); + const generatedBlock = formatGeneratedBlock(entries); + + const current = await readFile(CODEOWNERS_PATH, "utf8"); + const next = replaceGeneratedSection(current, generatedBlock); + + const checkOnly = process.argv.includes("--check"); + + if (unresolved.length > 0) { + console.warn("Unresolved ownership units (add to scripts/codeowners-overrides.json):"); + for (const entry of unresolved) console.warn(` ${entry.path} (${entry.email || "no email"})`); + } + + if (checkOnly) { + if (next !== current) { + console.error("CODEOWNERS is out of date. Run: pnpm scripts:generate-codeowners"); + process.exit(1); + } + return; + } + + await writeFile(CODEOWNERS_PATH, next, "utf8"); + console.log(`Updated CODEOWNERS with ${entries.length} feature units.`); +}; + +await main();