Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
510c9b945d | ||
|
|
2405e1b931 |
No files matched your search
@@ -0,0 +1,2 @@
|
||||
-P ubuntu-latest=catthehacker/ubuntu:act-latest
|
||||
--container-architecture linux/amd64
|
||||
@@ -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?
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
name: Update CODEOWNERS
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
pull_request:
|
||||
paths:
|
||||
- CODEOWNERS
|
||||
- packages/widgets/src/**
|
||||
- packages/integrations/src/**
|
||||
- packages/api/src/router/widgets/**
|
||||
- packages/cron-jobs/src/jobs/**
|
||||
- scripts/generate-codeowners.mts
|
||||
- scripts/validate-codeowners.mts
|
||||
- scripts/codeowners-overrides.json
|
||||
push:
|
||||
paths:
|
||||
- packages/widgets/src/**
|
||||
- packages/integrations/src/**
|
||||
- packages/api/src/router/widgets/**
|
||||
- packages/cron-jobs/src/jobs/**
|
||||
- scripts/generate-codeowners.mts
|
||||
- scripts/validate-codeowners.mts
|
||||
- scripts/codeowners-overrides.json
|
||||
branches:
|
||||
- dev
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate CODEOWNERS
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check CODEOWNERS is up to date
|
||||
run: pnpm exec tsx ./scripts/generate-codeowners.mts --check
|
||||
|
||||
- name: Validate CODEOWNERS
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: pnpm exec tsx ./scripts/validate-codeowners.mts
|
||||
|
||||
update:
|
||||
name: Open CODEOWNERS update PR
|
||||
needs: validate
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
concurrency:
|
||||
group: update-codeowners
|
||||
cancel-in-progress: false
|
||||
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 }}
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate CODEOWNERS
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
run: pnpm scripts:generate-codeowners
|
||||
|
||||
- name: Validate CODEOWNERS
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: pnpm exec tsx ./scripts/validate-codeowners.mts
|
||||
|
||||
- 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
|
||||
+83
@@ -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
|
||||
@@ -25,6 +25,8 @@
|
||||
"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",
|
||||
"scripts:validate-codeowners": "tsx ./scripts/validate-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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
"anchor-note": "anchor-notes",
|
||||
"media-releases": "media-release",
|
||||
rssFeed: "rssFeed",
|
||||
stocks: "stocks",
|
||||
};
|
||||
|
||||
const INTEGRATION_CRON_ALIASES: Record<string, string> = {
|
||||
homeassistant: "home-assistant",
|
||||
"download-client": "downloads",
|
||||
"media-organizer": "media-organizer",
|
||||
"pi-hole": "dns-hole",
|
||||
"smart-home": "home-assistant",
|
||||
};
|
||||
|
||||
const WIDGET_TO_INTEGRATION_DIR: Record<string, string> = {
|
||||
"anchor-note": "anchor",
|
||||
};
|
||||
|
||||
const NESTED_WIDGET_ROUTER_PARENT: Record<string, string> = {
|
||||
"dns-hole": "dns-hole",
|
||||
immich: "immich",
|
||||
"media-requests": "media-requests",
|
||||
"network-controller": "network-controller",
|
||||
minecraft: "minecraft",
|
||||
"smart-home": "smart-home",
|
||||
};
|
||||
|
||||
type Overrides = Record<string, string>;
|
||||
|
||||
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<string>) => {
|
||||
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<string>([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<string>([
|
||||
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<string>();
|
||||
|
||||
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();
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "==> Local CODEOWNERS checks"
|
||||
pnpm exec tsx ./scripts/generate-codeowners.mts --check
|
||||
export GITHUB_REPOSITORY="${GITHUB_REPOSITORY:-homarr-labs/homarr}"
|
||||
if [ -z "${GITHUB_TOKEN:-}" ] && [ -z "${GH_TOKEN:-}" ]; then
|
||||
GITHUB_TOKEN="$(gh auth token 2>/dev/null || true)"
|
||||
export GITHUB_TOKEN
|
||||
fi
|
||||
pnpm exec tsx ./scripts/validate-codeowners.mts
|
||||
|
||||
echo "==> act: validate job (requires Docker)"
|
||||
act workflow_dispatch -W .github/workflows/update-codeowners.yml -j validate \
|
||||
-s GITHUB_TOKEN="${GITHUB_TOKEN:-}"
|
||||
@@ -0,0 +1,145 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
const REPO_ROOT = join(import.meta.dirname, "..");
|
||||
const CODEOWNERS_PATH = join(REPO_ROOT, "CODEOWNERS");
|
||||
|
||||
const BEGIN_MARKER = "# BEGIN GENERATED widget/integration owners";
|
||||
const END_MARKER = "# END GENERATED widget/integration owners";
|
||||
|
||||
const UNSUPPORTED_PATTERN_CHECKS: { label: string; pattern: RegExp }[] = [
|
||||
{ label: "negation (!)", pattern: /^\s*!/ },
|
||||
{ label: "character range ([ ])", pattern: /^\s*[^#]*\[[^\]]+\]/ },
|
||||
];
|
||||
|
||||
const parseOwners = (line: string) => [...line.matchAll(/@[\w-]+(?:\/[\w-]+)?/g)].map((match) => match[0]);
|
||||
|
||||
const parsePatterns = (line: string) => {
|
||||
const withoutOwners = line.replace(/@[\w-]+(?:\/[\w-]+)?/g, "").trim();
|
||||
return withoutOwners.split(/\s+/).filter((token) => token.length > 0 && !token.startsWith("#"));
|
||||
};
|
||||
|
||||
const validateLine = (line: string, lineNumber: number) => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0 || trimmed.startsWith("#")) return [];
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const check of UNSUPPORTED_PATTERN_CHECKS) {
|
||||
if (check.pattern.test(line)) {
|
||||
errors.push(`line ${lineNumber}: unsupported CODEOWNERS syntax (${check.label})`);
|
||||
}
|
||||
}
|
||||
|
||||
const owners = parseOwners(line);
|
||||
const patterns = parsePatterns(line);
|
||||
|
||||
if (patterns.length === 0 && owners.length === 0) return errors;
|
||||
|
||||
if (patterns.length > 0 && owners.length === 0) return errors;
|
||||
|
||||
if (owners.length > 0 && patterns.length === 0) {
|
||||
errors.push(`line ${lineNumber}: owners without file patterns`);
|
||||
}
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const allowedGlobal = pattern === "*";
|
||||
const hasValidShape =
|
||||
allowedGlobal || pattern.startsWith("/") || pattern.includes("*") || pattern.includes(".");
|
||||
if (!hasValidShape) {
|
||||
errors.push(`line ${lineNumber}: pattern "${pattern}" should start with / or contain a wildcard`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const owner of owners) {
|
||||
if (!owner.startsWith("@")) {
|
||||
errors.push(`line ${lineNumber}: invalid owner "${owner}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
const validateStructure = (content: string) => {
|
||||
const errors: string[] = [];
|
||||
const lines = content.split("\n");
|
||||
|
||||
if (!content.includes("* @")) {
|
||||
errors.push("missing default owner rule (* @team or * @user) — last matching pattern wins per GitHub docs");
|
||||
}
|
||||
|
||||
const starIndex = lines.findIndex((line) => /^\*(\s+|$)/.test(line.trim()));
|
||||
const beginIndex = lines.findIndex((line) => line.includes(BEGIN_MARKER));
|
||||
const endIndex = lines.findIndex((line) => line.includes(END_MARKER));
|
||||
|
||||
if (beginIndex === -1 || endIndex === -1) {
|
||||
errors.push("missing generated CODEOWNERS markers");
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (starIndex !== -1 && beginIndex !== -1 && starIndex > beginIndex) {
|
||||
errors.push("global * rule must appear before generated feature rules so per-folder owners override maintainers");
|
||||
}
|
||||
|
||||
if (endIndex <= beginIndex) {
|
||||
errors.push("END GENERATED marker must follow BEGIN GENERATED marker");
|
||||
}
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const lineErrors = validateLine(lines[index] ?? "", index + 1);
|
||||
errors.push(...lineErrors);
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
const validateGithubApi = async () => {
|
||||
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
||||
const repository = process.env.GITHUB_REPOSITORY ?? "homarr-labs/homarr";
|
||||
|
||||
if (!token) {
|
||||
console.warn("Skipping GitHub API CODEOWNERS check (GITHUB_TOKEN not set).");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`https://api.github.com/repos/${repository}/codeowners/errors`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API codeowners/errors failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { errors: unknown[] };
|
||||
const apiErrors = payload.errors ?? [];
|
||||
|
||||
if (apiErrors.length > 0) {
|
||||
console.error("GitHub reported CODEOWNERS syntax errors:");
|
||||
console.error(JSON.stringify(apiErrors, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("GitHub API: no CODEOWNERS syntax errors.");
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const content = await readFile(CODEOWNERS_PATH, "utf8");
|
||||
const errors = validateStructure(content);
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error("CODEOWNERS validation failed:");
|
||||
for (const error of errors) {
|
||||
console.error(` ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("CODEOWNERS structure validation passed.");
|
||||
await validateGithubApi();
|
||||
};
|
||||
|
||||
await main();
|
||||
Reference in new issue
Block a user