Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Output of `bun run build:graphql` (graphql-codegen). Committed so type-aware
# tooling resolves it without a codegen step, but marked generated so GitHub
# collapses it in diffs and omits it from language stats. CI re-runs codegen and
# fails on drift (see .github/workflows/test.yml).
src/github/graphql/generated.ts linguist-generated=true
4 changes: 4 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ jobs:
run: |
shellcheck scripts/analyze.sh scripts/analyze.test.sh
sh scripts/analyze.test.sh
- name: Verify generated GraphQL types are up to date
run: |
bun run build:graphql
git diff --exit-code src/github/graphql/generated.ts

test:
name: Test
Expand Down
5 changes: 3 additions & 2 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ before:
hooks:
- bun install --frozen-lockfile
# The CLI embeds dist/report-web/index.html via a `with { type: 'text' }`
# import, so the web report must be built before the binary is compiled.
# import and the collectors import generated GraphQL types, so both must be
# built before the binary is compiled. `build:assets` does both.
# `release --clean` wipes dist/ before these hooks run, so the order holds.
- bun run build:report-web
- bun run build:assets

builds:
- id: patchwave-analysis
Expand Down
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ claude-tmp/
bun.lock
.husky/_/

# Generated by graphql-codegen (bun run build:graphql)
src/github/graphql/generated.ts

# Managed by release-please; its formatting conflicts with prettier
CHANGELOG.md
.release-please-manifest.json
364 changes: 353 additions & 11 deletions bun.lock

Large diffs are not rendered by default.

35 changes: 35 additions & 0 deletions codegen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { CodegenConfig } from '@graphql-codegen/cli';

// Output is committed and regenerated via `bun run build:graphql`.
const config: CodegenConfig = {
schema: 'node_modules/@octokit/graphql-schema/schema.graphql',
documents: 'src/**/*.graphql',
generates: {
'src/github/graphql/generated.ts': {
plugins: ['typescript-operations', 'typed-document-node'],
config: {
scalars: {
Base64String: 'string',
BigInt: 'string',
Date: 'string',
DateTime: 'string',
GitObjectID: 'string',
GitRefname: 'string',
GitSSHRemote: 'string',
GitTimestamp: 'string',
HTML: 'string',
PreciseDateTime: 'string',
URI: 'string',
X509Certificate: 'string',
},
maybeValue: 'T | null',
preResolveTypes: true,
// The project sets `verbatimModuleSyntax`, so the generated module must
// import its types with `import type`.
useTypeImports: true,
},
},
},
};

export default config;
10 changes: 9 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,15 @@ const processRestrictedProperties = [
export default defineConfig(
...baseConfig,
{
ignores: ['dist/**', 'node_modules/**', 'coverage/**', 'storybook-static/**', 'claude-tmp/**', 'bun.lock'],
ignores: [
'dist/**',
'node_modules/**',
'coverage/**',
'storybook-static/**',
'claude-tmp/**',
'bun.lock',
'src/github/graphql/generated.ts',
],
},
{
rules: {
Expand Down
2 changes: 1 addition & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ run *args:

# Compile a host-platform binary (for cross-platform artifacts use `just release dry-run`)
build:
bun run build:report-web
bun run build:assets
bun build --compile ./src/index.ts --outfile dist/patchwave-analysis

# Run the embedded report UI dev server with HMR
Expand Down
30 changes: 20 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,27 @@
"README.md"
],
"scripts": {
"start": "bun run build:report-web && bun run src/index.ts",
"start": "bun run build:assets && bun run src/index.ts",
"test": "bun run test:unit && bun run test:browser",
"test:unit": "bun run build:report-web && bun test ./src/*.test.ts ./src/**/*.test.ts",
"test:unit": "bun run build:assets && bun test ./src/*.test.ts ./src/**/*.test.ts",
"test:browser": "vitest run",
"typecheck": "bun run build:report-web && tsc --noEmit",
"typecheck": "bun run build:assets && tsc --noEmit",
"lint": "eslint . --max-warnings 0 --cache",
"lint:fix": "eslint . --fix --cache",
"format": "prettier --write \"**/*.{ts,tsx,md,json,yaml,yml}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,md,json,yaml,yml}\"",
"prepare": "husky",
"prepack": "bun run build:report-web",
"prepack": "bun run build:assets",
"build:assets": "bun run build:graphql && bun run build:report-web",
"build:graphql": "graphql-codegen --config codegen.ts",
"build:report-web": "bun run scripts/build-report-web.ts",
"dev:report-web": "bun ./src/report/web/index.dev.html",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build --stats-json",
"chromatic": "chromatic",
"build:darwin-arm64": "bun run build:report-web && bun build --compile --target=bun-darwin-arm64 ./src/index.ts --outfile dist/patchwave-analysis-darwin-arm64",
"build:darwin-x64": "bun run build:report-web && bun build --compile --target=bun-darwin-x64 ./src/index.ts --outfile dist/patchwave-analysis-darwin-x64",
"build:linux-x64": "bun run build:report-web && bun build --compile --target=bun-linux-x64 ./src/index.ts --outfile dist/patchwave-analysis-linux-x64"
"build:darwin-arm64": "bun run build:assets && bun build --compile --target=bun-darwin-arm64 ./src/index.ts --outfile dist/patchwave-analysis-darwin-arm64",
"build:darwin-x64": "bun run build:assets && bun build --compile --target=bun-darwin-x64 ./src/index.ts --outfile dist/patchwave-analysis-darwin-x64",
"build:linux-x64": "bun run build:assets && bun build --compile --target=bun-linux-x64 ./src/index.ts --outfile dist/patchwave-analysis-linux-x64"
},
"prettier": "@contextbridge-ai/prettier-config",
"lint-staged": {
Expand All @@ -46,25 +48,32 @@
"@contextbridge-ai/prettier-config": "^0.0.0",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/ibm-plex-sans": "^5.2.8",
"@graphql-codegen/cli": "^7.1.0",
"@graphql-codegen/typed-document-node": "^7.0.2",
"@graphql-codegen/typescript-operations": "^6.0.3",
"@graphql-typed-document-node/core": "^3.2.0",
"@octokit/graphql-schema": "^15.26.1",
"@octokit/plugin-paginate-rest": "^14.0.0",
"@octokit/types": "^16.0.0",
"@storybook/react-vite": "^10.2.14",
"@tailwindcss/vite": "^4.3.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/browser-playwright": "^4.0.18",
"@types/bun": "latest",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/semver": "^7.7.1",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/browser-playwright": "^4.0.18",
"bun-plugin-tailwind": "^0.1.2",
"chromatic": "^15.1.1",
"eslint": "^10.4.0",
"fishery": "^2.4.0",
"globals": "^17.6.0",
"husky": "^9.1.7",
"lint-staged": "^17.0.5",
"prettier": "^3.8.3",
"playwright": "^1.58.2",
"prettier": "^3.8.3",
"storybook": "^10.2.14",
"tailwindcss": "^4",
"typescript": "^6.0.3",
Expand All @@ -82,6 +91,7 @@
"@sentry/bun": "^10.50.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"graphql": "^16.14.0",
"lucide-react": "^1.16.0",
"neverthrow": "^8.2.0",
"open": "^11.0.0",
Expand Down
32 changes: 6 additions & 26 deletions src/collectors/branchProtection.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,11 @@
import type { Endpoints } from '@octokit/types';
import { ResultAsync, errAsync, okAsync } from 'neverthrow';
import type { GithubError } from '../github/errors.ts';
import type { GithubClient } from '../github/GithubClient.ts';
import type { BranchProtectionSlice, BranchProtectionSource, RepoRef } from '../types.ts';

interface ClassicProtectionResponse {
required_pull_request_reviews?: {
required_approving_review_count?: number;
};
required_status_checks?: {
contexts?: string[];
} | null;
}

interface RulesetRule {
type: string;
parameters?: {
required_approving_review_count?: number;
};
}
type BranchRule = Endpoints['GET /repos/{owner}/{repo}/rules/branches/{branch}']['response']['data'][number];
type PullRequestRule = Extract<BranchRule, { type: 'pull_request' }>;

interface PartialProtection {
source: BranchProtectionSource;
Expand All @@ -42,11 +30,7 @@ function getClassicProtection(
branch: string,
): ResultAsync<PartialProtection | null, GithubError> {
return client
.request<ClassicProtectionResponse>('GET /repos/{owner}/{repo}/branches/{branch}/protection', {
owner: ref.owner,
repo: ref.name,
branch,
})
.request('GET /repos/{owner}/{repo}/branches/{branch}/protection', { owner: ref.owner, repo: ref.name, branch })
.map(
(data): PartialProtection => ({
source: 'classic',
Expand All @@ -70,14 +54,10 @@ function getRulesetProtection(
// to a branch from any active ruleset (repo-level or inherited). It does NOT
// include classic branch protection — that's still a separate endpoint.
return client
.request<RulesetRule[]>('GET /repos/{owner}/{repo}/rules/branches/{branch}', {
owner: ref.owner,
repo: ref.name,
branch,
})
.request('GET /repos/{owner}/{repo}/rules/branches/{branch}', { owner: ref.owner, repo: ref.name, branch })
.map((rules): PartialProtection | null => {
if (rules.length === 0) return null;
const prRule = rules.find((r) => r.type === 'pull_request');
const prRule = rules.find((r): r is PullRequestRule => r.type === 'pull_request');
const statusRule = rules.find((r) => r.type === 'required_status_checks');
const reviewCount = prRule?.parameters?.required_approving_review_count;
return {
Expand Down
25 changes: 12 additions & 13 deletions src/collectors/contributors.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,29 @@
import type { ResultAsync } from 'neverthrow';
import { z } from 'zod';
import type { GithubError } from '../github/errors.ts';
import type { GithubClient } from '../github/GithubClient.ts';
import type { ContributorSlice, RepoRef } from '../types.ts';

interface ListCommitsItem {
author: { login?: string; type?: string } | null;
commit: { author: { name: string; date: string } | null };
}
// Treat unmatched/deleted GitHub authors as anonymous and skip them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sometimes they don't even follow their own schema, like here.

const commitSchema = z.object({
author: z.object({ login: z.string(), type: z.string().optional() }).nullable().catch(null),
});

export function listActiveCommitters(
client: GithubClient,
ref: RepoRef,
windowStartIso: string,
): ResultAsync<ContributorSlice, GithubError> {
return client
.paginate<ListCommitsItem>('GET /repos/{owner}/{repo}/commits', {
owner: ref.owner,
repo: ref.name,
since: windowStartIso,
per_page: 100,
})
.paginate(
'GET /repos/{owner}/{repo}/commits',
{ owner: ref.owner, repo: ref.name, since: windowStartIso, per_page: 100 },
commitSchema,
)
.map((commits) => {
const logins = new Set<string>();
for (const c of commits) {
const author = c.author;
if (!author?.login) continue;
for (const { author } of commits) {
if (!author) continue;
if (author.type === 'Bot') continue;
if (author.login.endsWith('[bot]')) continue;
logins.add(author.login);
Expand Down
60 changes: 34 additions & 26 deletions src/collectors/cve.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,46 @@
import { type ResultAsync, errAsync, okAsync } from 'neverthrow';
import { z } from 'zod';
import type { GithubError } from '../github/errors.ts';
import type { GithubClient } from '../github/GithubClient.ts';
import type { CveAlert, CveSeverity, CveSlice, RepoRef } from '../types.ts';

interface RawCveAlert {
number: number;
state: string;
created_at: string;
security_advisory: { summary: string };
security_vulnerability: {
severity: string;
package: { name: string; ecosystem: string };
};
}
// `security_vulnerability` is null on alerts GitHub can't attribute to a
// concrete vulnerability (e.g. auto-dismissed); those carry no severity or
// package, so we skip them rather than crash.
const alertSchema = z.object({
number: z.number(),
created_at: z.string(),
security_advisory: z.object({ summary: z.string() }),
security_vulnerability: z
.object({
severity: z.string(),
package: z.object({ name: z.string(), ecosystem: z.string() }),
})
.nullable(),
});

export function getCveAlerts(client: GithubClient, ref: RepoRef): ResultAsync<CveSlice, GithubError> {
return client
.paginate<RawCveAlert>('GET /repos/{owner}/{repo}/dependabot/alerts', {
owner: ref.owner,
repo: ref.name,
state: 'open',
per_page: 100,
})
.paginate(
'GET /repos/{owner}/{repo}/dependabot/alerts',
{ owner: ref.owner, repo: ref.name, state: 'open', per_page: 100 },
alertSchema,
)
.map((raw): CveSlice => {
const alerts: CveAlert[] = raw.map((a) => ({
owner: ref.owner,
name: ref.name,
number: a.number,
severity: normalizeSeverity(a.security_vulnerability.severity),
createdAt: a.created_at,
packageName: a.security_vulnerability.package.name,
ecosystem: a.security_vulnerability.package.ecosystem,
summary: a.security_advisory.summary,
}));
const alerts: CveAlert[] = [];
for (const a of raw) {
if (a.security_vulnerability === null) continue;
alerts.push({
owner: ref.owner,
name: ref.name,
number: a.number,
severity: normalizeSeverity(a.security_vulnerability.severity),
createdAt: a.created_at,
packageName: a.security_vulnerability.package.name,
ecosystem: a.security_vulnerability.package.ecosystem,
summary: a.security_advisory.summary,
});
}
return { owner: ref.owner, name: ref.name, status: 'ok', alerts };
})
.orElse((err) => {
Expand Down
18 changes: 7 additions & 11 deletions src/collectors/dependabotConfig.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import type { Endpoints } from '@octokit/types';
import { Result, ResultAsync, errAsync, okAsync } from 'neverthrow';
import type { GithubError } from '../github/errors.ts';
import type { GithubClient } from '../github/GithubClient.ts';
import type { DependabotConfigSlice, DependabotInterval, DependabotUpdateEntry, RepoRef } from '../types.ts';

interface ContentResponse {
content?: string;
encoding?: string;
}
type ContentsResponse = Endpoints['GET /repos/{owner}/{repo}/contents/{path}']['response']['data'];

const CONFIG_PATHS = ['.github/dependabot.yml', '.github/dependabot.yaml'];

Expand Down Expand Up @@ -37,20 +35,18 @@ function fetchFirstAvailable(
const [head, ...rest] = paths;
if (head === undefined) return okAsync(null);
return client
.request<ContentResponse>('GET /repos/{owner}/{repo}/contents/{path}', {
owner: ref.owner,
repo: ref.name,
path: head,
})
.request('GET /repos/{owner}/{repo}/contents/{path}', { owner: ref.owner, repo: ref.name, path: head })
.map((data) => decodeContent(data))
.orElse((err) => {
if (err.kind === 'not-found') return fetchFirstAvailable(client, ref, rest);
return errAsync<string | null, GithubError>(err);
});
}

function decodeContent(data: ContentResponse): string | null {
if (!data.content) return null;
function decodeContent(data: ContentsResponse): string | null {
// A directory path returns an array; symlinks and submodules carry no inline
// content. Only a regular file has a base64 `content` body to decode.
if (Array.isArray(data) || !('content' in data) || !data.content) return null;
if (data.encoding && data.encoding !== 'base64') return null;
return Buffer.from(data.content, 'base64').toString('utf8');
}
Expand Down
Loading
Loading