diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d91a92a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: 'npm' # See documentation for possible values + directory: '/' # Location of package manifests + schedule: + interval: 'weekly' diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..9b089d8 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,21 @@ +name: Release Please + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - name: Run Release Please + uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.prettierignore b/.prettierignore index 91a3983..e904c05 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ dist node_modules package-lock.json +__INTERNAL__ diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..b870c5e --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "1.0.2" +} diff --git a/LICENSE b/LICENSE index dfa9038..238e9fb 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Really Him +Copyright (c) 2026 Really Him Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..6bb8d93 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,32 @@ +# Maintainers + +This file documents the release workflow for this repository. + +## Release automation (Release Please) + +- Workflow: `.github/workflows/release-please.yml` +- Trigger: pushes to `main` (or manual run via `workflow_dispatch`) +- Behavior: opens or updates a release PR and manages `CHANGELOG.md` +- Merge: merging the release PR creates the Git tag and GitHub Release + +## Release checklist + +1. Ensure commit messages follow conventional commits: + - `fix:` for patch releases + - `feat:` for minor releases + - `feat!:` or `BREAKING CHANGE:` for major releases +2. Verify `dist/` is up to date if any `src/` files changed since the last release: + - run `npm run bundle` + - if `dist/` changes, commit them to the release PR +3. Confirm CI is green on the release PR +4. Merge the release PR + +## After merge + +- Confirm the GitHub Release is published (not a draft) +- Verify the Marketplace listing reflects the new release tag/version + +## Notes + +- `CHANGELOG.md` is generated by Release Please; avoid manual edits. +- If no release PR is created, confirm there are new conventional commits since the last tag. diff --git a/README.md b/README.md index da10833..c8f7b7f 100644 --- a/README.md +++ b/README.md @@ -18,24 +18,27 @@ This action captures the rate-limit state at job start and compares it with the ## Usage +To use this action, just drop it in anywhere in your job - the pre- and post-job hooks will do all of the work. + ```yaml jobs: - track: + search: runs-on: ubuntu-latest - outputs: - usage: ${{ steps.usage.outputs.usage }} steps: - - uses: actions/checkout@v4 - - uses: hesreallyhim/github-api-usage-tracker@v1 - id: usage - - report: - runs-on: ubuntu-latest - needs: track - steps: - - run: echo "Core API used: ${{ needs.track.outputs.usage }}" + - name Checkout + uses: actions/checkout@v4 + - name Track Usage + uses: hesreallyhim/github-api-usage-tracker@v1 + - name: Query API + uses: actions/github-script@v6 + with: + script: | + const response = await ... + ... ``` +After your job completes, you'll get a nice summary: +
Reset Window Crossed: Yes (${crossedBuckets.join(', ')})
`, + true + ); + } + summary .addRaw( `Action Duration: ${ hasStartTime ? formatMs(duration) : 'Unknown (data missing)' diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..09a32fa --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,10 @@ +{ + "packages": { + ".": { + "release-type": "simple", + "package-name": "github-api-usage-tracker", + "changelog-path": "CHANGELOG.md", + "include-v-in-tag": true + } + } +} diff --git a/src/post-utils.js b/src/post-utils.js index 63c43f8..82dd859 100644 --- a/src/post-utils.js +++ b/src/post-utils.js @@ -35,4 +35,76 @@ function makeSummaryTable(resources) { return summaryTable; } -module.exports = { formatMs, makeSummaryTable }; +/** + * Computes usage stats for a single bucket using pre/post snapshots. + * + * @param {object} startingBucket - bucket from the pre snapshot. + * @param {object} endingBucket - bucket from the post snapshot. + * @param {number} endTimeSeconds - post snapshot time in seconds. + * @returns {object} usage details and validation status. + */ +function computeBucketUsage(startingBucket, endingBucket, endTimeSeconds) { + const result = { + valid: false, + used: 0, + remaining: undefined, + crossed_reset: false, + warnings: [] + }; + + if (!startingBucket || !endingBucket) { + result.reason = 'missing_bucket'; + return result; + } + + const startingRemaining = Number(startingBucket.remaining); + const endingRemaining = Number(endingBucket.remaining); + if (!Number.isFinite(startingRemaining) || !Number.isFinite(endingRemaining)) { + result.reason = 'invalid_remaining'; + return result; + } + + const startingLimit = Number(startingBucket.limit); + const endingLimit = Number(endingBucket.limit); + const resetPre = Number(startingBucket.reset); + const crossedReset = Number.isFinite(resetPre) && endTimeSeconds >= resetPre; + result.crossed_reset = crossedReset; + + let used; + if (crossedReset) { + if (!Number.isFinite(startingLimit) || !Number.isFinite(endingLimit)) { + result.reason = 'invalid_limit'; + return result; + } + if (startingLimit !== endingLimit) { + result.warnings.push('limit_changed_across_reset'); + } + used = startingLimit - startingRemaining + (endingLimit - endingRemaining); + } else { + if ( + Number.isFinite(startingLimit) && + Number.isFinite(endingLimit) && + startingLimit !== endingLimit + ) { + result.reason = 'limit_changed_without_reset'; + return result; + } + used = startingRemaining - endingRemaining; + if (used < 0) { + result.reason = 'remaining_increased_without_reset'; + return result; + } + } + + if (used < 0) { + result.reason = 'negative_usage'; + return result; + } + + result.valid = true; + result.used = used; + result.remaining = endingRemaining; + return result; +} + +module.exports = { formatMs, makeSummaryTable, computeBucketUsage }; diff --git a/src/post.js b/src/post.js index 9eca844..39d8b0e 100644 --- a/src/post.js +++ b/src/post.js @@ -23,7 +23,7 @@ const fs = require('fs'); const path = require('path'); const { fetchRateLimit } = require('./rate-limit'); const { log, parseBuckets } = require('./log'); -const { formatMs, makeSummaryTable } = require('./post-utils'); +const { formatMs, makeSummaryTable, computeBucketUsage } = require('./post-utils'); /** * Writes JSON-stringified data to a file if a valid pathname is provided. @@ -75,6 +75,7 @@ async function run() { ); } const endTime = Date.now(); + const endTimeSeconds = Math.floor(endTime / 1000); const duration = hasStartTime ? endTime - startTime : null; log('[github-api-usage-tracker] Fetching final rate limits...'); @@ -87,33 +88,77 @@ async function run() { log(`[github-api-usage-tracker] ${JSON.stringify(endingResources, null, 2)}`); const data = {}; + const crossedBuckets = []; let totalUsed = 0; for (const bucket of buckets) { - const startingUsed = startingResources[bucket]?.used; - const endingUsed = endingResources[bucket]?.used; - if (startingUsed === undefined) { + const startingBucket = startingResources[bucket]; + const endingBucket = endingResources[bucket]; + if (!startingBucket) { core.warning( `[github-api-usage-tracker] Starting rate limit bucket "${bucket}" not found; skipping` ); continue; } - if (endingUsed === undefined) { + if (!endingBucket) { core.warning( `[github-api-usage-tracker] Ending rate limit bucket "${bucket}" not found; skipping` ); continue; } - let used = endingUsed - startingUsed; - if (used < 0) { + + const usage = computeBucketUsage(startingBucket, endingBucket, endTimeSeconds); + if (!usage.valid) { + switch (usage.reason) { + case 'invalid_remaining': + core.warning( + `[github-api-usage-tracker] Invalid remaining count for bucket "${bucket}"; skipping` + ); + break; + case 'invalid_limit': + core.warning( + `[github-api-usage-tracker] Invalid limit for bucket "${bucket}" during reset crossing; skipping` + ); + break; + case 'limit_changed_without_reset': + core.warning( + `[github-api-usage-tracker] Limit changed without reset for bucket "${bucket}"; skipping` + ); + break; + case 'remaining_increased_without_reset': + core.warning( + `[github-api-usage-tracker] Remaining increased without reset for bucket "${bucket}"; skipping` + ); + break; + case 'negative_usage': + core.warning( + `[github-api-usage-tracker] Negative usage for bucket "${bucket}" detected; skipping` + ); + break; + default: + core.warning( + `[github-api-usage-tracker] Invalid usage data for bucket "${bucket}"; skipping` + ); + break; + } + continue; + } + + if (usage.warnings.includes('limit_changed_across_reset')) { core.warning( - `[github-api-usage-tracker] Negative usage for bucket "${bucket}" detected; clamping to 0` + `[github-api-usage-tracker] Limit changed across reset for bucket "${bucket}"; results may reflect a token change` ); - used = 0; } - const remaining = endingResources[bucket].remaining; - data[bucket] = { used, remaining }; - totalUsed += used; + + data[bucket] = { + used: usage.used, + remaining: usage.remaining, + crossed_reset: usage.crossed_reset + }; + if (usage.crossed_reset) { + crossedBuckets.push(bucket); + } + totalUsed += usage.used; } // Set output @@ -131,9 +176,16 @@ async function run() { log( `[github-api-usage-tracker] Preparing summary table for ${Object.keys(data).length} bucket(s)` ); - core.summary + const summary = core.summary .addHeading('GitHub API Usage Tracker Summary') - .addTable(makeSummaryTable(data)) + .addTable(makeSummaryTable(data)); + if (crossedBuckets.length > 0) { + summary.addRaw( + `
Reset Window Crossed: Yes (${crossedBuckets.join(', ')})
`, + true + ); + } + summary .addRaw( `Action Duration: ${ hasStartTime ? formatMs(duration) : 'Unknown (data missing)' diff --git a/tests/post-utils.test.mjs b/tests/post-utils.test.mjs index bd4ffcd..9395fb0 100644 --- a/tests/post-utils.test.mjs +++ b/tests/post-utils.test.mjs @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { createRequire } from 'module'; const require = createRequire(import.meta.url); -const { formatMs, makeSummaryTable } = require('../src/post-utils.js'); +const { formatMs, makeSummaryTable, computeBucketUsage } = require('../src/post-utils.js'); describe('post utils', () => { it('formats sub-minute durations in seconds', () => { @@ -37,3 +37,87 @@ describe('post utils', () => { ]); }); }); + +describe('computeBucketUsage', () => { + it('computes usage within the same window', () => { + const result = computeBucketUsage( + { limit: 1000, remaining: 900, reset: 1600 }, + { limit: 1000, remaining: 850 }, + 1200 + ); + + expect(result).toEqual({ + valid: true, + used: 50, + remaining: 850, + crossed_reset: false, + warnings: [] + }); + }); + + it('marks remaining increases without reset as invalid', () => { + const result = computeBucketUsage( + { limit: 1000, remaining: 800, reset: 1600 }, + { limit: 1000, remaining: 900 }, + 1200 + ); + + expect(result).toEqual({ + valid: false, + used: 0, + remaining: undefined, + crossed_reset: false, + warnings: [], + reason: 'remaining_increased_without_reset' + }); + }); + + it('computes usage when a reset window is crossed', () => { + const result = computeBucketUsage( + { limit: 1000, remaining: 700, reset: 1100 }, + { limit: 1000, remaining: 900 }, + 1300 + ); + + expect(result).toEqual({ + valid: true, + used: 400, + remaining: 900, + crossed_reset: true, + warnings: [] + }); + }); + + it('warns when limits change across a reset', () => { + const result = computeBucketUsage( + { limit: 1000, remaining: 600, reset: 1100 }, + { limit: 5000, remaining: 4700 }, + 1300 + ); + + expect(result).toEqual({ + valid: true, + used: 700, + remaining: 4700, + crossed_reset: true, + warnings: ['limit_changed_across_reset'] + }); + }); + + it('marks limit changes mid-window as invalid', () => { + const result = computeBucketUsage( + { limit: 1000, remaining: 950, reset: 1600 }, + { limit: 5000, remaining: 4900 }, + 1200 + ); + + expect(result).toEqual({ + valid: false, + used: 0, + remaining: undefined, + crossed_reset: false, + warnings: [], + reason: 'limit_changed_without_reset' + }); + }); +});