Compare commits

...

3 Commits
v2.3.0 ... v3.0

Author SHA1 Message Date
release bot
aebc3a9285 📦 3.0.0 2023-07-17 20:57:38 +00:00
platane
1574f65738 📓 update readme 2023-07-17 22:55:37 +02:00
platane
ebeb59fced read contribution calendar from github api 2023-07-17 22:55:37 +02:00
20 changed files with 189 additions and 2763 deletions

View File

@@ -17,6 +17,8 @@ jobs:
- run: npm run type
- run: npm run lint
- run: npm run test --ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
test-action:
runs-on: ubuntu-latest
@@ -36,6 +38,8 @@ jobs:
dist/github-contribution-grid-snake.svg
dist/github-contribution-grid-snake-dark.svg?palette=github-dark
dist/github-contribution-grid-snake.gif?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: ensure the generated file exists
run: |
@@ -75,6 +79,8 @@ jobs:
outputs: |
dist/github-contribution-grid-snake.svg
dist/github-contribution-grid-snake-dark.svg?palette=github-dark
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: ensure the generated file exists
run: |

3
.gitignore vendored
View File

@@ -3,4 +3,5 @@ npm-debug.log*
yarn-error.log*
dist
!svg-only/dist
build
build
.env

View File

@@ -35,7 +35,7 @@ Available as github action. It can automatically generate a new image each day.
**github action**
```yaml
- uses: Platane/snk@v2
- uses: Platane/snk@v3
with:
# github user name to read the contribution graph from (**required**)
# using action context var `github.repository_owner` or specified user
@@ -54,6 +54,10 @@ Available as github action. It can automatically generate a new image each day.
dist/github-snake.svg
dist/github-snake-dark.svg?palette=github-dark
dist/ocean.gif?color_snake=orange&color_dots=#bfd6f6,#8dbdff,#64a1f4,#4b91f1,#3c7dd9
env:
# a github token is required to fetch the contribution calendar from github API
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```
[example with cron job](https://github.com/Platane/Platane/blob/master/.github/workflows/main.yml#L24-L29)

View File

@@ -4,7 +4,7 @@ author: "platane"
runs:
using: docker
image: docker://platane/snk@sha256:2115ffeb538e355aa155630e6e32b6d77ea2345fa8584645c41ace7f5ad667fc
image: docker://platane/snk@sha256:753878055e52fbbaf3148fdac4590e396f97581f1dc4c1f861701add7a1dc1b5
inputs:
github_user_name:

View File

@@ -1,7 +1,7 @@
{
"name": "snk",
"description": "Generates a snake game from a github user contributions grid",
"version": "2.3.0",
"version": "3.0.0",
"private": true,
"repository": "github:platane/snk",
"devDependencies": {

View File

@@ -2,6 +2,8 @@ import * as fs from "fs";
import * as path from "path";
import { generateContributionSnake } from "../generateContributionSnake";
import { parseOutputsOption } from "../outputsOptions";
import { config } from "dotenv";
config({ path: __dirname + "/../../../.env" });
jest.setTimeout(2 * 60 * 1000);
@@ -30,7 +32,9 @@ it(
const outputs = parseOutputsOption(entries);
const results = await generateContributionSnake("platane", outputs);
const results = await generateContributionSnake("platane", outputs, {
githubToken: process.env.GITHUB_TOKEN!,
});
expect(results[0]).toBeDefined();
expect(results[1]).toBeDefined();

View File

@@ -12,10 +12,11 @@ export const generateContributionSnake = async (
format: "svg" | "gif";
drawOptions: DrawOptions;
animationOptions: AnimationOptions;
} | null)[]
} | null)[],
options: { githubToken: string }
) => {
console.log("🎣 fetching github user contribution");
const cells = await getGithubUserContribution(userName);
const cells = await getGithubUserContribution(userName, options);
const grid = userContributionToGrid(cells);
const snake = snake4;

View File

@@ -12,11 +12,14 @@ import { parseOutputsOption } from "./outputsOptions";
core.getInput("svg_out_path"),
]
);
const githubToken = process.env.GITHUB_TOKEN!;
const { generateContributionSnake } = await import(
"./generateContributionSnake"
);
const results = await generateContributionSnake(userName, outputs);
const results = await generateContributionSnake(userName, outputs, {
githubToken,
});
outputs.forEach((out, i) => {
const result = results[i];

View File

@@ -10,7 +10,8 @@
"@snk/types": "1.0.0"
},
"devDependencies": {
"@vercel/ncc": "0.36.1"
"@vercel/ncc": "0.36.1",
"dotenv": "16.3.1"
},
"scripts": {
"build": "ncc build --external canvas --external gifsicle --out dist ./index.ts",

View File

@@ -1,10 +1,11 @@
import path from "path";
import HtmlWebpackPlugin from "html-webpack-plugin";
import type { Configuration as WebpackConfiguration } from "webpack";
import type { Configuration as WebpackDevServerConfiguration } from "webpack-dev-server";
import webpack from "webpack";
import { getGithubUserContribution } from "@snk/github-user-contribution";
import { config } from "dotenv";
import type { Configuration as WebpackConfiguration } from "webpack";
import type { Configuration as WebpackDevServerConfiguration } from "webpack-dev-server";
config({ path: __dirname + "/../../.env" });
const demos: string[] = require("./demo.json");
@@ -13,7 +14,11 @@ const webpackDevServerConfiguration: WebpackDevServerConfiguration = {
onAfterSetupMiddleware: ({ app }) => {
app!.get("/api/github-user-contribution/:userName", async (req, res) => {
const userName: string = req.params.userName;
res.send(await getGithubUserContribution(userName));
res.send(
await getGithubUserContribution(userName, {
githubToken: process.env.GITHUB_TOKEN!,
})
);
});
},
};

View File

@@ -7,7 +7,11 @@ export default async (req: VercelRequest, res: VercelResponse) => {
try {
res.setHeader("Access-Control-Allow-Origin", "https://platane.github.io");
res.statusCode = 200;
res.json(await getGithubUserContribution(userName as string));
res.json(
await getGithubUserContribution(userName as string, {
githubToken: process.env.GITHUB!,
})
);
} catch (err) {
console.error(err);
res.statusCode = 500;

View File

@@ -1,19 +0,0 @@
import { formatParams } from "../formatParams";
const params = [
//
[{}, ""],
[{ year: 2017 }, "from=2017-01-01&to=2017-12-31"],
[{ from: "2017-12-03" }, "from=2017-12-03"],
[{ to: "2017-12-03" }, "to=2017-12-03"],
] as const;
params.forEach(([params, res]) =>
it(`should format ${JSON.stringify(params)}`, () => {
expect(formatParams(params)).toBe(res);
})
);
it("should fail if the date is in the future", () => {
expect(() => formatParams({ to: "9999-01-01" })).toThrow(Error);
});

View File

@@ -1,9 +1,18 @@
import { getGithubUserContribution } from "..";
import { config } from "dotenv";
config({ path: __dirname + "/../../../.env" });
describe("getGithubUserContribution", () => {
const promise = getGithubUserContribution("platane");
const promise = getGithubUserContribution("platane", {
githubToken: process.env.GITHUB_TOKEN!,
});
it("should resolve", async () => {
console.log(
"process.env.GITHUB_TOKEN",
process.env.GITHUB_TOKEN?.replace(/\d/g, "x")
);
await promise;
});
@@ -27,9 +36,3 @@ describe("getGithubUserContribution", () => {
expect(undefinedDays).toEqual([]);
});
});
xit("should match snapshot for year=2019", async () => {
expect(
await getGithubUserContribution("platane", { year: 2019 })
).toMatchSnapshot();
});

View File

@@ -1,38 +0,0 @@
export type Options = { from?: string; to?: string } | { year: number };
export const formatParams = (options: Options = {}) => {
const sp = new URLSearchParams();
const o: any = { ...options };
if ("year" in options) {
o.from = `${options.year}-01-01`;
o.to = `${options.year}-12-31`;
}
for (const s of ["from", "to"])
if (o[s]) {
const value = o[s];
if (value >= formatDate(new Date()))
throw new Error(
"Cannot get contribution for a date in the future.\nPlease limit your range to the current UTC day."
);
sp.set(s, value);
}
return sp.toString();
};
const formatDate = (d: Date) => {
const year = d.getUTCFullYear();
const month = d.getUTCMonth() + 1;
const date = d.getUTCDate();
return [
year,
month.toString().padStart(2, "0"),
date.toString().padStart(2, "0"),
].join("-");
};

View File

@@ -1,5 +1,4 @@
import fetch from "node-fetch";
import { formatParams, Options } from "./formatParams";
/**
* get the contribution grid from a github user page
@@ -19,57 +18,83 @@ import { formatParams, Options } from "./formatParams";
*/
export const getGithubUserContribution = async (
userName: string,
options: Options = {}
o: { githubToken: string }
) => {
// either use github.com/users/xxxx/contributions for previous years
// or github.com/xxxx ( which gives the latest update to today result )
const url =
"year" in options || "from" in options || "to" in options
? `https://github.com/users/${userName}/contributions?` +
formatParams(options)
: `https://github.com/${userName}`;
const query = /* GraphQL */ `
query ($login: String!) {
user(login: $login) {
contributionsCollection {
contributionCalendar {
weeks {
contributionDays {
contributionCount
contributionLevel
weekday
date
}
}
}
}
}
}
`;
const variables = { login: userName };
const res = await fetch(url);
const res = await fetch("https://api.github.com/graphql", {
headers: {
Authorization: `bearer ${o.githubToken}`,
"Content-Type": "application/json",
},
method: "POST",
body: JSON.stringify({ variables, query }),
});
if (!res.ok) throw new Error(res.statusText);
const resText = await res.text();
const { data, errors } = (await res.json()) as {
data: GraphQLRes;
errors?: { message: string }[];
};
return parseUserPage(resText);
if (errors?.[0]) throw errors[0];
return data.user.contributionsCollection.contributionCalendar.weeks.flatMap(
({ contributionDays }, x) =>
contributionDays.map((d) => ({
x,
y: d.weekday,
date: d.date,
count: d.contributionCount,
level:
(d.contributionLevel === "FOURTH_QUARTILE" && 4) ||
(d.contributionLevel === "THIRD_QUARTILE" && 3) ||
(d.contributionLevel === "SECOND_QUARTILE" && 2) ||
(d.contributionLevel === "FIRST_QUARTILE" && 1) ||
0,
}))
);
};
const parseUserPage = (content: string) => {
// take roughly the table block
const block = content
.split(`aria-describedby="contribution-graph-description"`)[1]
.split("<tbody>")[1]
.split("</tbody>")[0];
const cells = block.split("</tr>").flatMap((inside, y) =>
inside.split("</td>").flatMap((m) => {
const date = m.match(/data-date="([^"]+)"/)?.[1];
const literalLevel = m.match(/data-level="([^"]+)"/)?.[1];
const literalX = m.match(/data-ix="([^"]+)"/)?.[1];
const literalCount = m.match(/(No|\d+) contributions? on/)?.[1];
if (date && literalLevel && literalX && literalCount)
return [
{
x: +literalX,
y,
date,
count: +literalCount,
level: +literalLevel,
},
];
return [];
})
);
return cells;
type GraphQLRes = {
user: {
contributionsCollection: {
contributionCalendar: {
weeks: {
contributionDays: {
contributionCount: number;
contributionLevel:
| "FOURTH_QUARTILE"
| "THIRD_QUARTILE"
| "SECOND_QUARTILE"
| "FIRST_QUARTILE"
| "NONE";
date: string;
weekday: number;
}[];
}[];
};
};
};
};
export type Res = Awaited<ReturnType<typeof getGithubUserContribution>>;

View File

@@ -5,6 +5,7 @@
"node-fetch": "2.6.12"
},
"devDependencies": {
"@types/node-fetch": "2.6.2"
"@types/node-fetch": "2.6.4",
"dotenv": "16.3.1"
}
}

View File

@@ -1,9 +1,9 @@
"use strict";
exports.id = 317;
exports.ids = [317];
exports.id = 407;
exports.ids = [407];
exports.modules = {
/***/ 5317:
/***/ 407:
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
// ESM COMPAT FLAG
@@ -17,37 +17,8 @@ __webpack_require__.d(__webpack_exports__, {
// EXTERNAL MODULE: ../../node_modules/node-fetch/lib/index.js
var lib = __webpack_require__(2197);
var lib_default = /*#__PURE__*/__webpack_require__.n(lib);
;// CONCATENATED MODULE: ../github-user-contribution/formatParams.ts
const formatParams = (options = {}) => {
const sp = new URLSearchParams();
const o = { ...options };
if ("year" in options) {
o.from = `${options.year}-01-01`;
o.to = `${options.year}-12-31`;
}
for (const s of ["from", "to"])
if (o[s]) {
const value = o[s];
if (value >= formatDate(new Date()))
throw new Error("Cannot get contribution for a date in the future.\nPlease limit your range to the current UTC day.");
sp.set(s, value);
}
return sp.toString();
};
const formatDate = (d) => {
const year = d.getUTCFullYear();
const month = d.getUTCMonth() + 1;
const date = d.getUTCDate();
return [
year,
month.toString().padStart(2, "0"),
date.toString().padStart(2, "0"),
].join("-");
};
;// CONCATENATED MODULE: ../github-user-contribution/index.ts
/**
* get the contribution grid from a github user page
*
@@ -64,43 +35,50 @@ const formatDate = (d) => {
* getGithubUserContribution("platane", { year: 2019 })
*
*/
const getGithubUserContribution = async (userName, options = {}) => {
// either use github.com/users/xxxx/contributions for previous years
// or github.com/xxxx ( which gives the latest update to today result )
const url = "year" in options || "from" in options || "to" in options
? `https://github.com/users/${userName}/contributions?` +
formatParams(options)
: `https://github.com/${userName}`;
const res = await lib_default()(url);
const getGithubUserContribution = async (userName, o) => {
const query = /* GraphQL */ `
query ($login: String!) {
user(login: $login) {
contributionsCollection {
contributionCalendar {
weeks {
contributionDays {
contributionCount
contributionLevel
weekday
date
}
}
}
}
}
}
`;
const variables = { login: userName };
const res = await lib_default()("https://api.github.com/graphql", {
headers: {
Authorization: `bearer ${o.githubToken}`,
"Content-Type": "application/json",
},
method: "POST",
body: JSON.stringify({ variables, query }),
});
if (!res.ok)
throw new Error(res.statusText);
const resText = await res.text();
return parseUserPage(resText);
};
const parseUserPage = (content) => {
// take roughly the table block
const block = content
.split(`aria-describedby="contribution-graph-description"`)[1]
.split("<tbody>")[1]
.split("</tbody>")[0];
const cells = block.split("</tr>").flatMap((inside, y) => inside.split("</td>").flatMap((m) => {
const date = m.match(/data-date="([^"]+)"/)?.[1];
const literalLevel = m.match(/data-level="([^"]+)"/)?.[1];
const literalX = m.match(/data-ix="([^"]+)"/)?.[1];
const literalCount = m.match(/(No|\d+) contributions? on/)?.[1];
if (date && literalLevel && literalX && literalCount)
return [
{
x: +literalX,
y,
date,
count: +literalCount,
level: +literalLevel,
},
];
return [];
}));
return cells;
const { data, errors } = (await res.json());
if (errors?.[0])
throw errors[0];
return data.user.contributionsCollection.contributionCalendar.weeks.flatMap(({ contributionDays }, x) => contributionDays.map((d) => ({
x,
y: d.weekday,
date: d.date,
count: d.contributionCount,
level: (d.contributionLevel === "FOURTH_QUARTILE" && 4) ||
(d.contributionLevel === "THIRD_QUARTILE" && 3) ||
(d.contributionLevel === "SECOND_QUARTILE" && 2) ||
(d.contributionLevel === "FIRST_QUARTILE" && 1) ||
0,
})));
};
// EXTERNAL MODULE: ../types/grid.ts
@@ -655,9 +633,9 @@ const getPathToPose = (snake0, target, grid) => {
const generateContributionSnake = async (userName, outputs) => {
const generateContributionSnake = async (userName, outputs, options) => {
console.log("🎣 fetching github user contribution");
const cells = await getGithubUserContribution(userName);
const cells = await getGithubUserContribution(userName, options);
const grid = userContributionToGrid(cells);
const snake = snake4;
console.log("📡 computing best route");

View File

@@ -3098,8 +3098,11 @@ const parseEntry = (entry) => {
core.getInput("gif_out_path"),
core.getInput("svg_out_path"),
]);
const { generateContributionSnake } = await Promise.all(/* import() */[__nccwpck_require__.e(197), __nccwpck_require__.e(317)]).then(__nccwpck_require__.bind(__nccwpck_require__, 5317));
const results = await generateContributionSnake(userName, outputs);
const githubToken = process.env.GITHUB_TOKEN;
const { generateContributionSnake } = await Promise.all(/* import() */[__nccwpck_require__.e(197), __nccwpck_require__.e(407)]).then(__nccwpck_require__.bind(__nccwpck_require__, 407));
const results = await generateContributionSnake(userName, outputs, {
githubToken,
});
outputs.forEach((out, i) => {
const result = results[i];
if (out?.filename && result) {

View File

@@ -1264,14 +1264,6 @@
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==
"@types/node-fetch@2.6.2":
version "2.6.2"
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.2.tgz#d1a9c5fd049d9415dce61571557104dec3ec81da"
integrity sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==
dependencies:
"@types/node" "*"
form-data "^3.0.0"
"@types/node-fetch@2.6.3":
version "2.6.3"
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.3.tgz#175d977f5e24d93ad0f57602693c435c57ad7e80"
@@ -1280,6 +1272,14 @@
"@types/node" "*"
form-data "^3.0.0"
"@types/node-fetch@2.6.4":
version "2.6.4"
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.4.tgz#1bc3a26de814f6bf466b25aeb1473fa1afe6a660"
integrity sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==
dependencies:
"@types/node" "*"
form-data "^3.0.0"
"@types/node@*":
version "14.0.23"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.23.tgz#676fa0883450ed9da0bb24156213636290892806"
@@ -2695,6 +2695,11 @@ dot-case@^3.0.4:
no-case "^3.0.4"
tslib "^2.0.3"
dotenv@16.3.1:
version "16.3.1"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e"
integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==
download@^6.2.2:
version "6.2.5"
resolved "https://registry.yarnpkg.com/download/-/download-6.2.5.tgz#acd6a542e4cd0bb42ca70cfc98c9e43b07039714"