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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ pnpm --filter @lukasparke/diffler-remotion test
└─────────────────┘ └──────────────────┘ └─────────────────┘
```

1. **diffler** collects comprehensive GitHub stats via GraphQL + REST, with caching, rate-limit budgeting, and resumable backfill
2. The collected stats are written as JSON conforming to the v2 schema
1. **diffler** collects comprehensive GitHub stats via GraphQL + REST and package install stats through registry adapters
2. Provider-specific package data is normalized alongside GitHub data in the v2 JSON schema
3. **@lukasparke/diffler-remotion** reads the JSON and renders animated profile cards

## GitHub Actions
Expand Down
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ rendering.

## Next

- Add a PyPI package-stats adapter using the normalized package metrics seam.
- Add a consumer fixture that exercises the released GitHub Action.
- Consolidate the three Remotion export commands.
- Add image-link validation to the profile repository.
Expand Down
14 changes: 14 additions & 0 deletions packages/diffler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,13 @@ statsAction:
includePrivateRepositoryMetrics: true
includePrivateRepositoryDetails: false
includePrivateCacheDetails: false
# Package names are explicit so only packages you own or maintain are shown.
# Every provider is normalized through the package stats adapter interface.
packageSources:
- provider: "npm"
packages:
- "@octocat/example"
- "another-package"
```

Private aggregate collection requires a token that can read the intended private
Expand All @@ -175,6 +182,13 @@ repository secret. You can also enable this mode with
`STATS_INCLUDE_PRIVATE_REPOSITORY_METRICS=true` or
`diffler collect --include-private-metrics`.

Package stats currently support npm download totals for the last day, week,
month, year, and all time, plus the latest version and publish date. Configure
npm from YAML as above, with `STATS_NPM_PACKAGES` as a comma-separated list, or
with `diffler collect --npm-packages "@octocat/example,another-package"`.
The output and cards use provider-neutral package metrics so future registries
such as PyPI can be added as adapters without changing consumers.

---

## 🤖 GitHub Actions
Expand Down
17 changes: 17 additions & 0 deletions packages/diffler/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ program
"--include-private-metrics",
"Include anonymous private repository metrics without repository details"
)
.option(
"--npm-packages <packages>",
"Comma-separated npm package names to include in package stats"
)
.option("--include-private", "Include private repository details")
.action(async (options) => {
const token = process.env.GITHUB_TOKEN;
Expand Down Expand Up @@ -126,6 +130,19 @@ program
statsConfig.includePrivateRepositoryDetails = true;
statsConfig.includePrivateCacheDetails = true;
}
const npmPackages = options.npmPackages
?.split(",")
.map((packageName: string) => packageName.trim())
.filter(Boolean);
if (npmPackages?.length) {
statsConfig.packageSources = [
...statsConfig.packageSources.filter((source) => source.provider !== "npm"),
{
provider: "npm",
packages: npmPackages,
},
];
}

const client = new GitHubClient(config.github);
await runStatsCollection(statsConfig, client);
Expand Down
21 changes: 21 additions & 0 deletions packages/diffler/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ export const StatsActionConfigSchema = z.object({
includePrivateRepositoryDetails: z.boolean().default(false),
includePrivateCacheDetails: z.boolean().default(false),
backfillMode: z.enum(["resume", "refresh", "off"]).default("resume"),
packageSources: z
.array(
z.object({
provider: z.string().min(1),
packages: z.array(z.string().min(1)),
})
)
.default([]),
});

export type StatsActionConfig = z.infer<typeof StatsActionConfigSchema>;
Expand Down Expand Up @@ -167,6 +175,19 @@ export function buildStatsActionConfig(config: DifflerConfig): StatsActionConfig
if (backfillMode === "resume" || backfillMode === "refresh" || backfillMode === "off") {
base.backfillMode = backfillMode;
}
const npmPackages = env("npm-packages", "")
.split(",")
.map((packageName) => packageName.trim())
.filter(Boolean);
if (npmPackages.length > 0) {
const configuredSources = base.packageSources.filter(
(source) => source.provider !== "npm"
);
base.packageSources = [
...configuredSources,
{ provider: "npm", packages: npmPackages },
];
}

return base;
}
Expand Down
7 changes: 7 additions & 0 deletions packages/diffler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,10 @@ export { DifflerConfig, GitHubConfig, GitHubProfileConfig } from "./config.js";
export { Engine } from "./core/engine.js";
export { Renderer } from "./core/renderer.js";
export { ContextBuilder } from "./core/context.js";
export {
collectPackageStats,
NpmPackageStatsAdapter,
type PackageStatsAdapter,
type PackageStatsAdapterResult,
type PackageStatsCollection,
} from "./packages/index.js";
88 changes: 88 additions & 0 deletions packages/diffler/src/packages/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type {
PackageDownloadCounts,
PackageMetric,
PackageSourceConfig,
PackageStatsAdapter,
PackageStatsCollection,
} from "./types.js";
import { NpmPackageStatsAdapter } from "./npm.js";

const EMPTY_DOWNLOADS: PackageDownloadCounts = {
lastDay: 0,
lastWeek: 0,
lastMonth: 0,
lastYear: 0,
allTime: 0,
};

export async function collectPackageStats(
sources: PackageSourceConfig[],
adapters: PackageStatsAdapter[] = [new NpmPackageStatsAdapter()]
): Promise<PackageStatsCollection> {
const adaptersByProvider = new Map(
adapters.map((adapter) => [adapter.provider, adapter])
);
const packages: PackageMetric[] = [];
const warnings: string[] = [];
const packagesByProvider = new Map<string, Set<string>>();

for (const source of sources) {
const packageNames = packagesByProvider.get(source.provider) ?? new Set<string>();
source.packages.forEach((packageName) => packageNames.add(packageName));
packagesByProvider.set(source.provider, packageNames);
}

for (const [provider, packageNames] of packagesByProvider) {
const adapter = adaptersByProvider.get(provider);
if (!adapter) {
warnings.push(`No package stats adapter registered for "${provider}"`);
continue;
}

try {
const result = await adapter.collect([...packageNames]);
packages.push(...result.packages);
warnings.push(...result.warnings);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (error) {
warnings.push(
`${provider} package stats unavailable: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}

return {
packageCount: packages.length,
providers: [...new Set(packages.map((item) => item.provider))],
downloads: sumDownloads(packages),
packages: packages.sort(
(left, right) => right.downloads.lastMonth - left.downloads.lastMonth
),
complete: warnings.length === 0,
warnings,
};
}

function sumDownloads(packages: PackageMetric[]): PackageDownloadCounts {
return packages.reduce<PackageDownloadCounts>(
(total, item) => ({
lastDay: total.lastDay + item.downloads.lastDay,
lastWeek: total.lastWeek + item.downloads.lastWeek,
lastMonth: total.lastMonth + item.downloads.lastMonth,
lastYear: total.lastYear + item.downloads.lastYear,
allTime: total.allTime + item.downloads.allTime,
}),
{ ...EMPTY_DOWNLOADS }
);
}

export { NpmPackageStatsAdapter } from "./npm.js";
export type {
PackageDownloadCounts,
PackageMetric,
PackageSourceConfig,
PackageStatsAdapter,
PackageStatsAdapterResult,
PackageStatsCollection,
} from "./types.js";
Loading
Loading