Skip to content
Open
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
52 changes: 52 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: CodeQL

on:
push:
branches: main
pull_request:
workflow_dispatch:
schedule:
- cron: '30 13 * * 6'

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}

jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest

permissions:
actions: read
contents: read
security-events: write

strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 24
cache: 'npm'

- name: Install dependencies
run: npm install

- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
queries: security-and-quality

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"
53 changes: 53 additions & 0 deletions .github/workflows/update-test-tag.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Update Test Tag

on:
push:
branches:
- main
workflow_dispatch:

permissions:
contents: write

jobs:
update-tag:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0

- name: Setup Node
uses: actions/setup-node@v5
with:
node-version: 24

- name: Install dependencies
run: npm ci

- name: Build action (updates dist/index.js)
run: npm run build

- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

- name: Commit dist changes (if any)
run: |
git add dist/
if git diff --cached --quiet; then
echo "No dist changes to commit."
else
git commit -m "chore: rebuild dist"
git push origin HEAD:main
fi

- name: Create or update tag 'test' to HEAD
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Updating tag 'test' to HEAD ($GITHUB_SHA)"
git tag -f test HEAD
git push --force origin refs/tags/test
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,30 @@ In the sample, only `GITHUB_TOKEN` and `USERNAME` are specified as environment v
- `SETTING_JSON` : (optional) settings json file path. See `sample-settings/*.json` and `src/type.ts` in `yoshi389111/github-profile-3d-contrib` repository for details. - since ver. 0.6.0
- `GITHUB_ENDPOINT` : (optional) Github GraphQL endpoint. For example, if you want to create a contribution calendar based on your company's GitHub Enterprise activity instead of GitHub.com, set this environment variable. e.g. `https://github.mycompany.com/api/graphql` - since ver. 0.8.0
- `YEAR` : (optional) For past calendars, specify the year. This is intended to be specified when running the tool from the command line. - since ver. 0.8.0
- `OUTPUT_PATH` : (optional) override the destination folder for generated assets; defaults to `./profile-3d-contrib`. Use this when wanting to write directly in a custom directory.
- `CALENDAR_START_DATE` : (optional) ISO-formatted date (for example `2024-01-01`). When set, the contribution calendar starts from this date instead of the `YEAR` range.
- `CALENDAR_END_DATE` : (optional) ISO-formatted date. Defaults to the current date when omitted. Only to be used when `CALENDAR_START_DATE` is used.

`CALENDAR_START_DATE` overrides `YEAR` so that you can track contributions for a custom range.

> [!IMPORTANT]
> GitHub GraphQL rejects `contributionsCollection(from, to)` ranges that span more than 1 year.
> If `CALENDAR_START_DATE` + `CALENDAR_END_DATE` (or “today” when `CALENDAR_END_DATE` is omitted) would exceed 1 year, this action automatically **shifts the start date forward** to keep a rolling ~365-day window ending at `CALENDAR_END_DATE`/today.

When you do not need a custom range, leave those variables unset and the workflow continues to fetch a single year as before.

If you want to store the generated SVGs somewhere other than the repository root, set `OUTPUT_PATH`. The folder will be created automatically, so you can push straight into `assets/profile-3d-contrib` without moving files.

```yaml
- uses: yoshi389111/github-profile-3d-contrib@latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
USERNAME: ${{ github.repository_owner }}
# Additional Optional commands
# OUTPUT_PATH: profile-3d-contrib/
# CALENDAR_START_DATE: 2024-02-01
# CALENDAR_END_DATE: 2025-01-31
```

#### About `GITHUB_TOKEN`

Expand Down
133 changes: 120 additions & 13 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ const aggregateUserInfo = (response) => {
}
}
const user = response.data.user;
if (!user) {
if (response.errors && response.errors.length) {
throw new Error(response.errors[0].message);
}
throw new Error('GraphQL response data.user is null');
}
const calendar = user.contributionsCollection.contributionCalendar.weeks
.flatMap((week) => week.contributionDays)
.map((week) => ({
Expand Down Expand Up @@ -1023,10 +1029,13 @@ exports.createSvg = createSvg;
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.writeFile = exports.OUTPUT_FOLDER = void 0;
const fs_1 = __nccwpck_require__(79896);
exports.OUTPUT_FOLDER = './profile-3d-contrib';
const DEFAULT_OUTPUT_FOLDER = './profile-3d-contrib';
exports.OUTPUT_FOLDER = DEFAULT_OUTPUT_FOLDER;
const getOutputFolder = () => process.env.OUTPUT_PATH || DEFAULT_OUTPUT_FOLDER;
const writeFile = (fileName, content) => {
(0, fs_1.mkdirSync)(exports.OUTPUT_FOLDER, { recursive: true });
(0, fs_1.writeFileSync)(`${exports.OUTPUT_FOLDER}/${fileName}`, content);
const folder = getOutputFolder();
(0, fs_1.mkdirSync)(folder, { recursive: true });
(0, fs_1.writeFileSync)(`${folder}/${fileName}`, content);
};
exports.writeFile = writeFile;
//# sourceMappingURL=file-writer.js.map
Expand All @@ -1046,18 +1055,32 @@ exports.fetchData = exports.fetchNext = exports.fetchFirst = exports.URL = void
const axios_1 = __importDefault(__nccwpck_require__(87269));
exports.URL = process.env.GITHUB_ENDPOINT || 'https://api.github.com/graphql';
const maxReposOneQuery = 100;
const fetchFirst = async (token, userName, year = null) => {
const yearArgs = year
? `(from:"${year}-01-01T00:00:00.000Z", to:"${year}-12-31T23:59:59.000Z")`
: '';
const buildCalendarArgs = (range) => {
if (!range) {
return '';
}
const args = [];
if (range.from) {
args.push(`from:"${range.from}"`);
}
if (range.to) {
args.push(`to:"${range.to}"`);
}
if (args.length === 0) {
return '';
}
return `(${args.join(', ')})`;
};
const fetchFirst = async (token, userName, calendarRange) => {
const calendarArgs = buildCalendarArgs(calendarRange);
const headers = {
Authorization: `bearer ${token}`,
};
const request = {
query: `
query($login: String!) {
user(login: $login) {
contributionsCollection${yearArgs} {
contributionsCollection${calendarArgs} {
contributionCalendar {
isHalloween
totalContributions
Expand Down Expand Up @@ -1138,15 +1161,17 @@ const fetchNext = async (token, userName, cursor) => {
};
exports.fetchNext = fetchNext;
/** Fetch data from GitHub GraphQL */
const fetchData = async (token, userName, maxRepos, year = null) => {
const res1 = await (0, exports.fetchFirst)(token, userName, year);
const fetchData = async (token, userName, maxRepos, calendarRange) => {
const res1 = await (0, exports.fetchFirst)(token, userName, calendarRange);
const result = res1.data;
if (result && result.user.repositories.nodes.length === maxReposOneQuery) {
// GraphQL may return `{ data: { user: null }, errors: [...] }` (rate limit, auth issues, etc).
// Never assume `user` exists here; let the caller handle errors gracefully.
if (result && result.user && result.user.repositories.nodes.length === maxReposOneQuery) {
const repos1 = result.user.repositories;
let cursor = repos1.edges[repos1.edges.length - 1].cursor;
while (repos1.nodes.length < maxRepos) {
const res2 = await (0, exports.fetchNext)(token, userName, cursor);
if (res2.data) {
if (res2.data && res2.data.user) {
const repos2 = res2.data.user.repositories;
repos1.nodes.push(...repos2.nodes);
if (repos2.nodes.length !== maxReposOneQuery) {
Expand Down Expand Up @@ -1203,6 +1228,15 @@ const create = __importStar(__nccwpck_require__(63336));
const f = __importStar(__nccwpck_require__(46045));
const r = __importStar(__nccwpck_require__(11820));
const client = __importStar(__nccwpck_require__(54652));
const parseDateFromEnv = (value, label) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
core.setFailed(`${label} is invalid`);
return null;
}
return date;
};
const DAY_MS = 24 * 60 * 60 * 1000;
const main = async () => {
try {
const token = process.env.GITHUB_TOKEN;
Expand All @@ -1227,7 +1261,80 @@ const main = async () => {
core.setFailed('YEAR is NaN');
return;
}
const response = await client.fetchData(token, userName, maxRepos, year);
const calendarStartEnv = process.env.CALENDAR_START_DATE;
const calendarEndEnv = process.env.CALENDAR_END_DATE;
let calendarRange;
if (calendarStartEnv) {
const userStartDate = parseDateFromEnv(calendarStartEnv, 'CALENDAR_START_DATE');
if (!userStartDate) {
return;
}
const endDate = calendarEndEnv
? parseDateFromEnv(calendarEndEnv, 'CALENDAR_END_DATE')
: new Date();
if (!endDate) {
return;
}
if (userStartDate > endDate) {
core.setFailed('CALENDAR_START_DATE must be on or before CALENDAR_END_DATE');
return;
}
// GitHub GraphQL rejects contribution ranges spanning > 1 year.
// Keep the graph up to date by ending at `endDate` (defaults to now), and shifting the start forward when needed.
// Also avoid showing "pre-history" null days by never starting earlier than the user-provided start.
const rollingStartDate = new Date(endDate.getTime() - 364 * DAY_MS);
const effectiveStartDate = userStartDate > rollingStartDate
? userStartDate
: rollingStartDate;
if (effectiveStartDate.getTime() !== userStartDate.getTime()) {
core.info(`CALENDAR_START_DATE adjusted to ${effectiveStartDate.toISOString()} to satisfy GitHub's 1-year limit`);
}
calendarRange = {
from: effectiveStartDate.toISOString(),
to: endDate.toISOString(),
};
}
else if (year !== null) {
const startOfYear = new Date(Date.UTC(year, 0, 1, 0, 0, 0));
const endOfYear = new Date(Date.UTC(year, 11, 31, 23, 59, 59));
calendarRange = {
from: startOfYear.toISOString(),
to: endOfYear.toISOString(),
};
}
const response = await client.fetchData(token, userName, maxRepos, calendarRange);
const isRateLimitError = (msg) => {
if (!msg)
return false;
return /rate limit|rateLimit|exceeded/i.test(msg);
};
if (!response || !response.data) {
if (response && response.errors && response.errors.length) {
const msg = response.errors[0].message || '';
if (isRateLimitError(msg)) {
core.info('GitHub GraphQL rate limit exceeded: ' + msg);
// exit gracefully so workflows don't crash.
return;
}
core.setFailed(response.errors[0].message);
}
else {
console.error('Empty GraphQL response:', JSON.stringify(response, null, 2));
core.setFailed('Empty GraphQL response');
}
return;
}
if (!response.data.user) {
// If the API responded with errors, treat rate-limit specially.
const errMsg = response.errors && response.errors.length ? response.errors[0].message : undefined;
if (isRateLimitError(errMsg)) {
core.info('GitHub GraphQL rate limit exceeded: ' + errMsg);
return;
}
console.error('GraphQL response missing `user` field:', JSON.stringify(response, null, 2));
core.setFailed('GraphQL response missing `user` — check USERNAME and token');
return;
}
const userInfo = aggregate.aggregateUserInfo(response);
if (process.env.SETTING_JSON) {
const settingFile = r.readSettingJson(process.env.SETTING_JSON);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"start": "node .",
"dev": "ts-node src/index.ts",
"dev:watch": "ts-node-dev --respawn src/index.ts",
"clean": "rimraf dist/*",
"clean": "rimraf dist",
"tsc": "tsc",
"build": "npm-run-all clean tsc && ncc build dist0/index.js --license licenses.txt",
"check-types": "tsc --noEmit",
Expand Down
15 changes: 15 additions & 0 deletions spec/file-writer.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { writeFile, OUTPUT_FOLDER } from "../src/file-writer";
import { rmSync, readFileSync } from 'fs';

const CUSTOM_OUTPUT_FOLDER = './custom-profile-output';

afterEach(() => {
rmSync(OUTPUT_FOLDER, { recursive: true, force: true });
rmSync(CUSTOM_OUTPUT_FOLDER, { recursive: true, force: true });
delete process.env.OUTPUT_PATH;
});

describe('file-writer', () => {
Expand All @@ -14,4 +18,15 @@ describe('file-writer', () => {
});
expect(content).toEqual('work');
});

it('respects OUTPUT_PATH override', () => {
process.env.OUTPUT_PATH = CUSTOM_OUTPUT_FOLDER;
const fileName = 'custom-output.svg';
writeFile(fileName, 'custom');
const content = readFileSync(`${CUSTOM_OUTPUT_FOLDER}/${fileName}`, {
encoding: 'utf8',
flag: 'r',
});
expect(content).toEqual('custom');
});
});
6 changes: 6 additions & 0 deletions src/aggregate-user-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ export const aggregateUserInfo = (
}

const user = response.data.user;
if (!user) {
if (response.errors && response.errors.length) {
throw new Error(response.errors[0].message);
}
throw new Error('GraphQL response data.user is null');
}
const calendar = user.contributionsCollection.contributionCalendar.weeks
.flatMap((week) => week.contributionDays)
.map((week) => ({
Expand Down
12 changes: 9 additions & 3 deletions src/file-writer.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { mkdirSync, writeFileSync } from 'fs';

export const OUTPUT_FOLDER = './profile-3d-contrib';
const DEFAULT_OUTPUT_FOLDER = './profile-3d-contrib';

export const OUTPUT_FOLDER = DEFAULT_OUTPUT_FOLDER;

const getOutputFolder = (): string =>
process.env.OUTPUT_PATH || DEFAULT_OUTPUT_FOLDER;

export const writeFile = (fileName: string, content: string): void => {
mkdirSync(OUTPUT_FOLDER, { recursive: true });
writeFileSync(`${OUTPUT_FOLDER}/${fileName}`, content);
const folder = getOutputFolder();
mkdirSync(folder, { recursive: true });
writeFileSync(`${folder}/${fileName}`, content);
};
Loading