@jgengine/github 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +325 -0
- package/dist/analytics.d.ts +27 -0
- package/dist/analytics.js +80 -0
- package/dist/calendar.d.ts +28 -0
- package/dist/calendar.js +66 -0
- package/dist/client.d.ts +34 -0
- package/dist/client.js +48 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/resources/actions.d.ts +26 -0
- package/dist/resources/actions.js +26 -0
- package/dist/resources/activity.d.ts +24 -0
- package/dist/resources/activity.js +36 -0
- package/dist/resources/issues.d.ts +42 -0
- package/dist/resources/issues.js +30 -0
- package/dist/resources/pulls.d.ts +46 -0
- package/dist/resources/pulls.js +24 -0
- package/dist/resources/repos.d.ts +55 -0
- package/dist/resources/repos.js +47 -0
- package/dist/resources/search.d.ts +14 -0
- package/dist/resources/search.js +19 -0
- package/dist/resources/users.d.ts +17 -0
- package/dist/resources/users.js +18 -0
- package/dist/server.d.ts +47 -0
- package/dist/server.js +195 -0
- package/dist/source.d.ts +17 -0
- package/dist/source.js +33 -0
- package/dist/wire.d.ts +18 -0
- package/dist/wire.js +1 -0
- package/package.json +36 -0
- package/skills/jgengine/SKILL.md +823 -0
- package/skills/jgengine/api.md +416 -0
- package/skills/jgengine/capabilities.md +21 -0
- package/skills/jgengine/reference-cartridge.md +72 -0
- package/skills/jgengine/reference-orphans.md +9 -0
- package/skills/jgengine/reference-systems.md +60 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
const USERNAME_PATTERN = /^[A-Za-z0-9-]{1,39}$/;
|
|
2
|
+
const API = "https://api.github.com";
|
|
3
|
+
const RESPONSE_HEADERS = {
|
|
4
|
+
"Content-Type": "application/json",
|
|
5
|
+
"Access-Control-Allow-Origin": "*",
|
|
6
|
+
"Cache-Control": "public, max-age=600",
|
|
7
|
+
};
|
|
8
|
+
/** Thrown by resolveContributions when the user does not exist (→ 404). */
|
|
9
|
+
export class GitHubUserNotFoundError extends Error {
|
|
10
|
+
}
|
|
11
|
+
async function fetchViaGraphQL(user, token) {
|
|
12
|
+
const query = `
|
|
13
|
+
query ($login: String!) {
|
|
14
|
+
user(login: $login) {
|
|
15
|
+
login
|
|
16
|
+
name
|
|
17
|
+
avatarUrl
|
|
18
|
+
contributionsCollection {
|
|
19
|
+
contributionCalendar {
|
|
20
|
+
totalContributions
|
|
21
|
+
weeks { contributionDays { date contributionCount weekday } }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
`;
|
|
27
|
+
const res = await fetch("https://api.github.com/graphql", {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "User-Agent": "jgengine" },
|
|
30
|
+
body: JSON.stringify({ query, variables: { login: user } }),
|
|
31
|
+
});
|
|
32
|
+
if (!res.ok)
|
|
33
|
+
throw new Error(`GitHub GraphQL API responded with ${res.status}`);
|
|
34
|
+
const payload = (await res.json());
|
|
35
|
+
if (payload.errors?.length)
|
|
36
|
+
throw new Error(payload.errors.map((e) => e.message).join("; "));
|
|
37
|
+
if (!payload.data?.user)
|
|
38
|
+
throw new GitHubUserNotFoundError(user);
|
|
39
|
+
const { login, name, avatarUrl, contributionsCollection } = payload.data.user;
|
|
40
|
+
const calendar = contributionsCollection.contributionCalendar;
|
|
41
|
+
return {
|
|
42
|
+
source: "graphql",
|
|
43
|
+
profile: { login, name, avatarUrl },
|
|
44
|
+
total: calendar.totalContributions,
|
|
45
|
+
weeks: calendar.weeks.map((week) => ({
|
|
46
|
+
days: week.contributionDays.map((day) => ({ date: day.date, count: day.contributionCount, weekday: day.weekday })),
|
|
47
|
+
})),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function parseTooltipCounts(html) {
|
|
51
|
+
const counts = new Map();
|
|
52
|
+
const pattern = /<tool-tip[^>]*for="([^"]+)"[^>]*>([\s\S]*?)<\/tool-tip>/g;
|
|
53
|
+
let match;
|
|
54
|
+
while ((match = pattern.exec(html)) !== null) {
|
|
55
|
+
const [, id, text] = match;
|
|
56
|
+
const countMatch = text.match(/(\d+)\s+contributions?/i);
|
|
57
|
+
counts.set(id, /no contributions/i.test(text) ? 0 : countMatch ? Number(countMatch[1]) : 0);
|
|
58
|
+
}
|
|
59
|
+
return counts;
|
|
60
|
+
}
|
|
61
|
+
export function parseContributionsHtml(html) {
|
|
62
|
+
const tooltipCounts = parseTooltipCounts(html);
|
|
63
|
+
const days = [];
|
|
64
|
+
const cellPattern = /<td\b[^>]*\bdata-date="(\d{4}-\d{2}-\d{2})"[^>]*>/g;
|
|
65
|
+
let match;
|
|
66
|
+
while ((match = cellPattern.exec(html)) !== null) {
|
|
67
|
+
const [tag, date] = match;
|
|
68
|
+
const id = tag.match(/\bid="([^"]*)"/)?.[1];
|
|
69
|
+
const count = id !== undefined ? tooltipCounts.get(id) ?? 0 : 0;
|
|
70
|
+
days.push({ date: date, count, weekday: new Date(`${date}T00:00:00Z`).getUTCDay() });
|
|
71
|
+
}
|
|
72
|
+
return days;
|
|
73
|
+
}
|
|
74
|
+
async function fetchViaScrape(user) {
|
|
75
|
+
const res = await fetch(`https://github.com/users/${encodeURIComponent(user)}/contributions`, {
|
|
76
|
+
headers: { "User-Agent": "jgengine" },
|
|
77
|
+
});
|
|
78
|
+
if (res.status === 404)
|
|
79
|
+
throw new GitHubUserNotFoundError(user);
|
|
80
|
+
if (!res.ok)
|
|
81
|
+
throw new Error(`GitHub responded with ${res.status}`);
|
|
82
|
+
const days = parseContributionsHtml(await res.text());
|
|
83
|
+
if (days.length === 0) {
|
|
84
|
+
throw new Error(`Could not read '${user}'’s contribution calendar — GitHub may have blocked the request or changed its page markup.`);
|
|
85
|
+
}
|
|
86
|
+
const weeks = [];
|
|
87
|
+
for (let i = 0; i < days.length; i += 7)
|
|
88
|
+
weeks.push({ days: days.slice(i, i + 7) });
|
|
89
|
+
return {
|
|
90
|
+
source: "scrape",
|
|
91
|
+
profile: { login: user, name: null, avatarUrl: null },
|
|
92
|
+
total: days.reduce((sum, day) => sum + day.count, 0),
|
|
93
|
+
weeks,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** Server-side: fetch a user's contribution calendar via GraphQL (with token) or HTML scrape. */
|
|
97
|
+
export function resolveContributions(user, options = {}) {
|
|
98
|
+
return options.token ? fetchViaGraphQL(user, options.token) : fetchViaScrape(user);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Turnkey proxy handler — mount at any route in any Web-`Response` server (Nitro,
|
|
102
|
+
* workers, Deno). Reads `?user=`, validates, proxies GitHub, returns JSON + CORS.
|
|
103
|
+
*/
|
|
104
|
+
export function githubContributionsHandler(options = {}) {
|
|
105
|
+
return async (request) => {
|
|
106
|
+
const user = new URL(request.url).searchParams.get("user");
|
|
107
|
+
if (!user || !USERNAME_PATTERN.test(user)) {
|
|
108
|
+
return json({ error: "Query param 'user' must be a valid GitHub username." }, 400);
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
return json(await resolveContributions(user, options));
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error instanceof GitHubUserNotFoundError)
|
|
115
|
+
return json({ error: `GitHub user '${user}' was not found.` }, 404);
|
|
116
|
+
return json({ error: error instanceof Error ? error.message : "Upstream failure" }, 502);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const PROXY_RESPONSE_HEADERS = {
|
|
121
|
+
"Content-Type": "application/json",
|
|
122
|
+
"Cache-Control": "private, no-store",
|
|
123
|
+
};
|
|
124
|
+
function proxyHeaders(token) {
|
|
125
|
+
const headers = { Accept: "application/vnd.github+json", "User-Agent": "jgengine" };
|
|
126
|
+
if (token !== undefined)
|
|
127
|
+
headers.Authorization = `Bearer ${token}`;
|
|
128
|
+
return headers;
|
|
129
|
+
}
|
|
130
|
+
async function passthrough(upstream) {
|
|
131
|
+
const headers = { ...PROXY_RESPONSE_HEADERS };
|
|
132
|
+
const link = upstream.headers.get("link");
|
|
133
|
+
if (link !== null)
|
|
134
|
+
headers.Link = link;
|
|
135
|
+
return new Response(await upstream.text(), { status: upstream.status, headers });
|
|
136
|
+
}
|
|
137
|
+
function proxyJson(body, status = 200) {
|
|
138
|
+
return new Response(JSON.stringify(body), { status, headers: PROXY_RESPONSE_HEADERS });
|
|
139
|
+
}
|
|
140
|
+
async function handleGraphQLOp(request, op, options) {
|
|
141
|
+
if (request.method !== "POST")
|
|
142
|
+
return proxyJson({ error: "GraphQL requires POST." }, 405);
|
|
143
|
+
const query = options.graphqlOperations?.[op];
|
|
144
|
+
if (query === undefined)
|
|
145
|
+
return proxyJson({ error: `GraphQL operation '${op}' is not allowed.` }, 403);
|
|
146
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
147
|
+
try {
|
|
148
|
+
const { variables } = (await request.json().catch(() => ({})));
|
|
149
|
+
const upstream = await doFetch(`${API}/graphql`, {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers: { ...proxyHeaders(options.token), "Content-Type": "application/json" },
|
|
152
|
+
body: JSON.stringify({ query, variables }),
|
|
153
|
+
});
|
|
154
|
+
return passthrough(upstream);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
return proxyJson({ error: error instanceof Error ? error.message : "Upstream failure" }, 502);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Named-operation GitHub proxy: forwards only the REST paths and GraphQL
|
|
162
|
+
* operations the caller explicitly allowlists, using the server-side token.
|
|
163
|
+
* Clients never supply raw GraphQL text or an unconstrained path — a bare
|
|
164
|
+
* mount with no `allowPath`/`graphqlOperations` forwards nothing at all.
|
|
165
|
+
*/
|
|
166
|
+
export function githubProxyHandler(options = {}) {
|
|
167
|
+
return async (request) => {
|
|
168
|
+
const url = new URL(request.url);
|
|
169
|
+
const op = url.searchParams.get("op");
|
|
170
|
+
if (op !== null)
|
|
171
|
+
return handleGraphQLOp(request, op, options);
|
|
172
|
+
const path = url.searchParams.get("path");
|
|
173
|
+
if (path === null || !path.startsWith("/")) {
|
|
174
|
+
return proxyJson({ error: "Query param 'path' must be an absolute GitHub API path." }, 400);
|
|
175
|
+
}
|
|
176
|
+
if (path === "/graphql" || path.startsWith("/graphql?")) {
|
|
177
|
+
return proxyJson({ error: "Use '?op=<name>' for GraphQL, not '?path=/graphql'." }, 403);
|
|
178
|
+
}
|
|
179
|
+
if (!(options.allowPath?.(path) ?? false)) {
|
|
180
|
+
return proxyJson({ error: `Path '${path}' is not allowed.` }, 403);
|
|
181
|
+
}
|
|
182
|
+
if (request.method !== "GET")
|
|
183
|
+
return proxyJson({ error: "Only GET reads are proxied." }, 405);
|
|
184
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
185
|
+
try {
|
|
186
|
+
return await passthrough(await doFetch(`${API}${path}`, { headers: proxyHeaders(options.token) }));
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
return proxyJson({ error: error instanceof Error ? error.message : "Upstream failure" }, 502);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function json(body, status = 200) {
|
|
194
|
+
return new Response(JSON.stringify(body), { status, headers: RESPONSE_HEADERS });
|
|
195
|
+
}
|
package/dist/source.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type DayCell } from "./calendar.js";
|
|
2
|
+
import type { ContributionsWire, GitHubProfile } from "./wire.js";
|
|
3
|
+
export type { ContributionsWire, GitHubProfile } from "./wire.js";
|
|
4
|
+
export interface ContributionData {
|
|
5
|
+
cells: DayCell[];
|
|
6
|
+
profile: GitHubProfile | null;
|
|
7
|
+
source: "graphql" | "scrape";
|
|
8
|
+
}
|
|
9
|
+
export declare const DEFAULT_ENDPOINT = "/api/github-contributions";
|
|
10
|
+
/** Flatten the proxy's week/day wire into positioned, leveled, labeled cells.
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
export declare function wireToCells(wire: ContributionsWire): DayCell[];
|
|
14
|
+
/** Browser client: fetch a user's contributions through the proxy endpoint.
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
export declare function contributions(user: string, endpoint?: string): Promise<ContributionData>;
|
package/dist/source.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { labelFromISO, levelForCount } from "./calendar.js";
|
|
2
|
+
export const DEFAULT_ENDPOINT = "/api/github-contributions";
|
|
3
|
+
/** Flatten the proxy's week/day wire into positioned, leveled, labeled cells.
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
export function wireToCells(wire) {
|
|
7
|
+
const cells = [];
|
|
8
|
+
wire.weeks.forEach((week, week_index) => {
|
|
9
|
+
for (const day of week.days) {
|
|
10
|
+
cells.push({
|
|
11
|
+
index: cells.length,
|
|
12
|
+
week: week_index,
|
|
13
|
+
weekday: day.weekday,
|
|
14
|
+
count: day.count,
|
|
15
|
+
level: levelForCount(day.count),
|
|
16
|
+
label: labelFromISO(day.date),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
return cells;
|
|
21
|
+
}
|
|
22
|
+
/** Browser client: fetch a user's contributions through the proxy endpoint.
|
|
23
|
+
* @internal
|
|
24
|
+
*/
|
|
25
|
+
export async function contributions(user, endpoint = DEFAULT_ENDPOINT) {
|
|
26
|
+
const response = await fetch(`${endpoint}?user=${encodeURIComponent(user)}`);
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
const body = (await response.json().catch(() => ({})));
|
|
29
|
+
throw new Error(body.error ?? `Lookup failed (${response.status})`);
|
|
30
|
+
}
|
|
31
|
+
const wire = (await response.json());
|
|
32
|
+
return { cells: wireToCells(wire), profile: wire.profile, source: wire.source };
|
|
33
|
+
}
|
package/dist/wire.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** The normalized JSON the proxy handler returns and the client consumes. */
|
|
2
|
+
export interface GitHubProfile {
|
|
3
|
+
login: string;
|
|
4
|
+
name: string | null;
|
|
5
|
+
avatarUrl: string | null;
|
|
6
|
+
}
|
|
7
|
+
export interface ContributionsWire {
|
|
8
|
+
source: "graphql" | "scrape";
|
|
9
|
+
profile: GitHubProfile | null;
|
|
10
|
+
total: number;
|
|
11
|
+
weeks: {
|
|
12
|
+
days: {
|
|
13
|
+
date: string;
|
|
14
|
+
count: number;
|
|
15
|
+
weekday: number;
|
|
16
|
+
}[];
|
|
17
|
+
}[];
|
|
18
|
+
}
|
package/dist/wire.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jgengine/github",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "GitHub data source for JGengine: contribution calendar fetch (GraphQL + HTML-scrape) with a server proxy handler, plus a browser client and contribution analytics.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Noisemaker111/jgengine.git",
|
|
11
|
+
"directory": "packages/github"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"CHANGELOG.md",
|
|
16
|
+
"skills"
|
|
17
|
+
],
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"default": "./dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./*": {
|
|
24
|
+
"types": "./dist/*.d.ts",
|
|
25
|
+
"default": "./dist/*.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsgo -p tsconfig.build.json && bun ../../scripts/fix-extensions.ts dist",
|
|
30
|
+
"check-types": "tsgo --noEmit -p tsconfig.json",
|
|
31
|
+
"test": "bun test src"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
}
|
|
36
|
+
}
|