@rmdes/indiekit-endpoint-github 1.0.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.
@@ -0,0 +1,60 @@
1
+ import { GitHubClient } from "../github-client.js";
2
+ import * as utils from "../utils.js";
3
+
4
+ /**
5
+ * Display PRs and issues created by user
6
+ * @type {import("express").RequestHandler}
7
+ */
8
+ export const contributionsController = {
9
+ async get(request, response, next) {
10
+ try {
11
+ const { username, token, cacheTtl, limits } =
12
+ request.app.locals.application.githubConfig;
13
+
14
+ if (!username) {
15
+ return response.render("contributions", {
16
+ title: response.locals.__("github.contributions.title"),
17
+ error: { message: response.locals.__("github.error.noUsername") },
18
+ });
19
+ }
20
+
21
+ const client = new GitHubClient({ token, cacheTtl });
22
+
23
+ let events = [];
24
+ try {
25
+ events = await client.getUserEvents(username, 100);
26
+ } catch (apiError) {
27
+ console.error("GitHub API error:", apiError);
28
+ return response.render("contributions", {
29
+ title: response.locals.__("github.contributions.title"),
30
+ actions: [],
31
+ parent: {
32
+ href: request.baseUrl,
33
+ text: response.locals.__("github.title"),
34
+ },
35
+ error: {
36
+ message: apiError.message || "Failed to fetch contributions",
37
+ },
38
+ });
39
+ }
40
+
41
+ const contributions = utils
42
+ .extractContributions(events)
43
+ .slice(0, limits.contributions * 2);
44
+
45
+ response.render("contributions", {
46
+ title: response.locals.__("github.contributions.title"),
47
+ actions: [],
48
+ parent: {
49
+ href: request.baseUrl,
50
+ text: response.locals.__("github.title"),
51
+ },
52
+ contributions,
53
+ username,
54
+ mountPath: request.baseUrl,
55
+ });
56
+ } catch (error) {
57
+ next(error);
58
+ }
59
+ },
60
+ };
@@ -0,0 +1,138 @@
1
+ import { GitHubClient } from "../github-client.js";
2
+ import * as utils from "../utils.js";
3
+
4
+ /**
5
+ * Display GitHub activity dashboard
6
+ * @type {import("express").RequestHandler}
7
+ */
8
+ export const dashboardController = {
9
+ async get(request, response, next) {
10
+ try {
11
+ console.log("[GitHub Endpoint] Dashboard controller started");
12
+
13
+ const { githubConfig } = request.app.locals.application;
14
+
15
+ if (!githubConfig) {
16
+ console.error("[GitHub Endpoint] ERROR: githubConfig is undefined");
17
+ return response.status(500).render("github", {
18
+ title: "GitHub",
19
+ actions: [],
20
+ error: { message: "GitHub endpoint not configured correctly" },
21
+ });
22
+ }
23
+
24
+ const { username, token, cacheTtl, limits, featuredRepos } = githubConfig;
25
+ console.log("[GitHub Endpoint] Config:", {
26
+ username,
27
+ hasToken: !!token,
28
+ cacheTtl,
29
+ limits,
30
+ featuredRepos: featuredRepos || [],
31
+ });
32
+
33
+ if (!username) {
34
+ console.log("[GitHub Endpoint] No username configured");
35
+ return response.render("github", {
36
+ title: response.locals.__("github.title"),
37
+ actions: [],
38
+ error: { message: response.locals.__("github.error.noUsername") },
39
+ });
40
+ }
41
+
42
+ const client = new GitHubClient({ token, cacheTtl });
43
+ console.log("[GitHub Endpoint] Using authenticated API:", !!token);
44
+
45
+ let user;
46
+ let events = [];
47
+ let starred = [];
48
+ let repos = [];
49
+
50
+ try {
51
+ console.log("[GitHub Endpoint] Fetching GitHub data for:", username);
52
+ [user, events, starred, repos] = await Promise.all([
53
+ client.getUser(username),
54
+ client.getUserEvents(username, 30),
55
+ client.getUserStarred(username, limits.stars),
56
+ client.getUserRepos(username, limits.repos || 10),
57
+ ]);
58
+ console.log("[GitHub Endpoint] Raw user data:", JSON.stringify(user));
59
+ console.log("[GitHub Endpoint] Events count:", events?.length);
60
+ console.log("[GitHub Endpoint] Event types:", events?.map(e => e.type));
61
+ console.log("[GitHub Endpoint] Starred count:", starred?.length);
62
+ console.log("[GitHub Endpoint] Repos count:", repos?.length);
63
+ } catch (apiError) {
64
+ console.error("[GitHub Endpoint] API error:", apiError);
65
+ return response.render("github", {
66
+ title: response.locals.__("github.title"),
67
+ actions: [],
68
+ error: { message: apiError.message || "Failed to fetch GitHub data" },
69
+ });
70
+ }
71
+
72
+ console.log("[GitHub Endpoint] Processing data...");
73
+ const commits = utils.extractCommits(events);
74
+ console.log("[GitHub Endpoint] Extracted commits:", commits?.length);
75
+
76
+ const contributions = utils.extractContributions(events);
77
+ console.log("[GitHub Endpoint] Extracted contributions:", contributions?.length);
78
+
79
+ const stars = utils.formatStarred(starred);
80
+ console.log("[GitHub Endpoint] Formatted stars:", stars?.length);
81
+
82
+ const repositories = utils.formatRepos(repos);
83
+ console.log("[GitHub Endpoint] Formatted repos:", repositories?.length);
84
+
85
+ // Fetch commits from featured repos
86
+ let featured = [];
87
+ if (featuredRepos && featuredRepos.length > 0) {
88
+ console.log("[GitHub Endpoint] Fetching featured repos:", featuredRepos);
89
+ const featuredPromises = featuredRepos.map(async (repoPath) => {
90
+ const [owner, repo] = repoPath.split("/");
91
+ try {
92
+ const [repoData, repoCommits] = await Promise.all([
93
+ client.getRepo(owner, repo),
94
+ client.getRepoCommits(owner, repo, 5),
95
+ ]);
96
+ return {
97
+ ...utils.formatRepos([repoData])[0],
98
+ commits: repoCommits.map((c) => ({
99
+ sha: c.sha.slice(0, 7),
100
+ message: utils.truncate(c.commit.message.split("\n")[0], 60),
101
+ url: c.html_url,
102
+ author: c.commit.author.name,
103
+ date: c.commit.author.date,
104
+ })),
105
+ };
106
+ } catch (error) {
107
+ console.error(`[GitHub Endpoint] Error fetching ${repoPath}:`, error.message);
108
+ return null;
109
+ }
110
+ });
111
+ featured = (await Promise.all(featuredPromises)).filter(Boolean);
112
+ console.log("[GitHub Endpoint] Featured repos loaded:", featured.length);
113
+ }
114
+
115
+ const starsLimit = limits.stars || 20;
116
+ const reposLimit = limits.repos || 10;
117
+
118
+ console.log("[GitHub Endpoint] Rendering with limits - stars:", starsLimit, "repos:", reposLimit);
119
+
120
+ response.render("github", {
121
+ title: response.locals.__("github.title"),
122
+ actions: [],
123
+ user,
124
+ commits: commits.slice(0, limits.commits || 10),
125
+ contributions: contributions.slice(0, limits.contributions || 5),
126
+ stars: stars.slice(0, starsLimit),
127
+ repositories: repositories.slice(0, reposLimit),
128
+ featured,
129
+ mountPath: request.baseUrl,
130
+ });
131
+ console.log("[GitHub Endpoint] Render complete");
132
+ } catch (error) {
133
+ console.error("[GitHub Endpoint] Unexpected error:", error);
134
+ console.error("[GitHub Endpoint] Stack:", error.stack);
135
+ next(error);
136
+ }
137
+ },
138
+ };
@@ -0,0 +1,116 @@
1
+ import { GitHubClient } from "../github-client.js";
2
+ import * as utils from "../utils.js";
3
+
4
+ /**
5
+ * Featured repos controller
6
+ */
7
+ export const featuredController = {
8
+ /**
9
+ * Get featured repos HTML page
10
+ * @type {import("express").RequestHandler}
11
+ */
12
+ async get(request, response, next) {
13
+ try {
14
+ const { githubConfig } = request.app.locals.application;
15
+
16
+ if (!githubConfig) {
17
+ return response.status(500).render("github", {
18
+ title: "GitHub",
19
+ actions: [],
20
+ error: { message: "GitHub endpoint not configured correctly" },
21
+ });
22
+ }
23
+
24
+ const { token, cacheTtl, featuredRepos } = githubConfig;
25
+
26
+ if (!featuredRepos || featuredRepos.length === 0) {
27
+ return response.render("featured", {
28
+ title: response.locals.__("github.featured.title"),
29
+ actions: [],
30
+ featured: [],
31
+ });
32
+ }
33
+
34
+ const client = new GitHubClient({ token, cacheTtl });
35
+ const featured = await fetchFeaturedRepos(client, featuredRepos);
36
+
37
+ response.render("featured", {
38
+ title: response.locals.__("github.featured.title"),
39
+ actions: [],
40
+ featured,
41
+ });
42
+ } catch (error) {
43
+ console.error("[GitHub Endpoint] Featured error:", error);
44
+ next(error);
45
+ }
46
+ },
47
+
48
+ /**
49
+ * Get featured repos JSON API
50
+ * @type {import("express").RequestHandler}
51
+ */
52
+ async api(request, response, next) {
53
+ try {
54
+ const { githubConfig } = request.app.locals.application;
55
+
56
+ if (!githubConfig) {
57
+ return response.status(500).json({ error: "GitHub not configured" });
58
+ }
59
+
60
+ const { token, cacheTtl, featuredRepos } = githubConfig;
61
+
62
+ if (!featuredRepos || featuredRepos.length === 0) {
63
+ return response.json({ featured: [] });
64
+ }
65
+
66
+ const client = new GitHubClient({ token, cacheTtl });
67
+ const featured = await fetchFeaturedRepos(client, featuredRepos);
68
+
69
+ response.json({ featured });
70
+ } catch (error) {
71
+ console.error("[GitHub Endpoint] Featured API error:", error);
72
+ next(error);
73
+ }
74
+ },
75
+ };
76
+
77
+ /**
78
+ * Fetch featured repos with commits
79
+ * @param {GitHubClient} client
80
+ * @param {string[]} featuredRepos
81
+ * @returns {Promise<Array>}
82
+ */
83
+ async function fetchFeaturedRepos(client, featuredRepos) {
84
+ console.log("[GitHub Endpoint] Fetching featured repos:", featuredRepos);
85
+
86
+ const featuredPromises = featuredRepos.map(async (repoPath) => {
87
+ const [owner, repo] = repoPath.split("/");
88
+ try {
89
+ const [repoData, repoCommits] = await Promise.all([
90
+ client.getRepo(owner, repo),
91
+ client.getRepoCommits(owner, repo, 5),
92
+ ]);
93
+ return {
94
+ ...utils.formatRepos([repoData])[0],
95
+ commits: repoCommits.map((c) => ({
96
+ sha: c.sha.slice(0, 7),
97
+ message: utils.truncate(c.commit.message.split("\n")[0], 60),
98
+ url: c.html_url,
99
+ author: c.commit.author.name,
100
+ date: c.commit.author.date,
101
+ })),
102
+ };
103
+ } catch (error) {
104
+ console.error(
105
+ `[GitHub Endpoint] Error fetching ${repoPath}:`,
106
+ error.message,
107
+ );
108
+ return null;
109
+ }
110
+ });
111
+
112
+ const featured = (await Promise.all(featuredPromises)).filter(Boolean);
113
+ console.log("[GitHub Endpoint] Featured repos loaded:", featured.length);
114
+
115
+ return featured;
116
+ }
@@ -0,0 +1,84 @@
1
+ import { GitHubClient } from "../github-client.js";
2
+ import * as utils from "../utils.js";
3
+
4
+ /**
5
+ * Display starred repositories
6
+ * @type {import("express").RequestHandler}
7
+ */
8
+ export const starsController = {
9
+ async get(request, response, next) {
10
+ try {
11
+ const { username, token, cacheTtl, limits } =
12
+ request.app.locals.application.githubConfig;
13
+
14
+ if (!username) {
15
+ return response.render("stars", {
16
+ title: response.locals.__("github.stars.title"),
17
+ error: { message: response.locals.__("github.error.noUsername") },
18
+ });
19
+ }
20
+
21
+ const client = new GitHubClient({ token, cacheTtl });
22
+
23
+ let starred = [];
24
+ try {
25
+ starred = await client.getUserStarred(username, limits.stars * 2);
26
+ } catch (apiError) {
27
+ console.error("GitHub API error:", apiError);
28
+ return response.render("stars", {
29
+ title: response.locals.__("github.stars.title"),
30
+ actions: [],
31
+ parent: {
32
+ href: request.baseUrl,
33
+ text: response.locals.__("github.title"),
34
+ },
35
+ error: { message: apiError.message || "Failed to fetch stars" },
36
+ });
37
+ }
38
+
39
+ const stars = utils.formatStarred(starred);
40
+
41
+ response.render("stars", {
42
+ title: response.locals.__("github.stars.title"),
43
+ actions: [],
44
+ parent: {
45
+ href: request.baseUrl,
46
+ text: response.locals.__("github.title"),
47
+ },
48
+ stars,
49
+ username,
50
+ mountPath: request.baseUrl,
51
+ });
52
+ } catch (error) {
53
+ next(error);
54
+ }
55
+ },
56
+
57
+ async api(request, response, next) {
58
+ try {
59
+ const { username, token, cacheTtl, limits } =
60
+ request.app.locals.application.githubConfig;
61
+
62
+ if (!username) {
63
+ return response.status(400).json({ error: "No username configured" });
64
+ }
65
+
66
+ const client = new GitHubClient({ token, cacheTtl });
67
+
68
+ let starred = [];
69
+ try {
70
+ starred = await client.getUserStarred(username, limits.stars);
71
+ } catch (apiError) {
72
+ return response
73
+ .status(apiError.status || 500)
74
+ .json({ error: apiError.message });
75
+ }
76
+
77
+ const stars = utils.formatStarred(starred);
78
+
79
+ response.json({ stars });
80
+ } catch (error) {
81
+ next(error);
82
+ }
83
+ },
84
+ };
@@ -0,0 +1,168 @@
1
+ import { IndiekitError } from "@indiekit/error";
2
+
3
+ const BASE_URL = "https://api.github.com";
4
+
5
+ export class GitHubClient {
6
+ /**
7
+ * @param {object} options - Client options
8
+ * @param {string} [options.token] - GitHub personal access token
9
+ * @param {number} [options.cacheTtl] - Cache TTL in milliseconds
10
+ */
11
+ constructor(options = {}) {
12
+ this.token = options.token;
13
+ this.cacheTtl = options.cacheTtl || 900_000;
14
+ this.cache = new Map();
15
+ }
16
+
17
+ /**
18
+ * Fetch from GitHub API with caching
19
+ * @param {string} endpoint - API endpoint
20
+ * @returns {Promise<object>} - Response data
21
+ */
22
+ async fetch(endpoint) {
23
+ const url = `${BASE_URL}${endpoint}`;
24
+
25
+ // Check cache first
26
+ const cached = this.cache.get(url);
27
+ if (cached && Date.now() - cached.timestamp < this.cacheTtl) {
28
+ return cached.data;
29
+ }
30
+
31
+ const headers = {
32
+ Accept: "application/vnd.github+json",
33
+ "X-GitHub-Api-Version": "2022-11-28",
34
+ };
35
+
36
+ if (this.token) {
37
+ headers.Authorization = `Bearer ${this.token}`;
38
+ }
39
+
40
+ const response = await fetch(url, { headers });
41
+
42
+ if (!response.ok) {
43
+ throw await IndiekitError.fromFetch(response);
44
+ }
45
+
46
+ const data = await response.json();
47
+
48
+ // Cache result
49
+ this.cache.set(url, { data, timestamp: Date.now() });
50
+
51
+ return data;
52
+ }
53
+
54
+ /**
55
+ * Get user's events (commits, PRs, issues, etc.)
56
+ * Uses authenticated endpoint if token available (includes private activity)
57
+ * @param {string} username - GitHub username
58
+ * @param {number} [limit] - Number of events to fetch
59
+ * @returns {Promise<Array>} - User events
60
+ */
61
+ async getUserEvents(username, limit = 30) {
62
+ // Use non-public endpoint when authenticated to include private repo activity
63
+ const endpoint = this.token
64
+ ? `/users/${username}/events?per_page=${limit}`
65
+ : `/users/${username}/events/public?per_page=${limit}`;
66
+ return this.fetch(endpoint);
67
+ }
68
+
69
+ /**
70
+ * Get commits for a specific repository
71
+ * @param {string} owner - Repository owner
72
+ * @param {string} repo - Repository name
73
+ * @param {number} [limit] - Number of commits to fetch
74
+ * @returns {Promise<Array>} - Repository commits
75
+ */
76
+ async getRepoCommits(owner, repo, limit = 10) {
77
+ return this.fetch(`/repos/${owner}/${repo}/commits?per_page=${limit}`);
78
+ }
79
+
80
+ /**
81
+ * Get user's starred repos
82
+ * @param {string} username - GitHub username
83
+ * @param {number} [limit] - Number of repos to fetch
84
+ * @returns {Promise<Array>} - Starred repositories
85
+ */
86
+ async getUserStarred(username, limit = 30) {
87
+ return this.fetch(
88
+ `/users/${username}/starred?per_page=${limit}&sort=created`,
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Get user profile
94
+ * @param {string} username - GitHub username
95
+ * @returns {Promise<object>} - User profile
96
+ */
97
+ async getUser(username) {
98
+ return this.fetch(`/users/${username}`);
99
+ }
100
+
101
+ /**
102
+ * Get user's repositories
103
+ * When authenticated, uses /user/repos to include private repos
104
+ * @param {string} username - GitHub username
105
+ * @param {number} [limit] - Number of repos to fetch
106
+ * @param {string} [sort] - Sort by: created, updated, pushed, full_name
107
+ * @returns {Promise<Array>} - User repositories
108
+ */
109
+ async getUserRepos(username, limit = 30, sort = "pushed") {
110
+ // When authenticated, use /user/repos for private repos access
111
+ // Then filter by owner to get only the user's repos (not org repos)
112
+ if (this.token) {
113
+ const repos = await this.fetch(
114
+ `/user/repos?per_page=${limit}&sort=${sort}&direction=desc&affiliation=owner`,
115
+ );
116
+ return repos;
117
+ }
118
+ // Unauthenticated: public repos only
119
+ return this.fetch(
120
+ `/users/${username}/repos?per_page=${limit}&sort=${sort}&direction=desc`,
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Get repo details
126
+ * @param {string} owner - Repository owner
127
+ * @param {string} repo - Repository name
128
+ * @returns {Promise<object>} - Repository details
129
+ */
130
+ async getRepo(owner, repo) {
131
+ return this.fetch(`/repos/${owner}/${repo}`);
132
+ }
133
+
134
+ /**
135
+ * Get repo events (activity from others)
136
+ * @param {string} owner - Repository owner
137
+ * @param {string} repo - Repository name
138
+ * @param {number} [limit] - Number of events to fetch
139
+ * @returns {Promise<Array>} - Repository events
140
+ */
141
+ async getRepoEvents(owner, repo, limit = 30) {
142
+ return this.fetch(`/repos/${owner}/${repo}/events?per_page=${limit}`);
143
+ }
144
+
145
+ /**
146
+ * Get user's PRs across all repos
147
+ * @param {string} username - GitHub username
148
+ * @param {number} [limit] - Number of PRs to fetch
149
+ * @returns {Promise<object>} - Search results with PRs
150
+ */
151
+ async getUserPRs(username, limit = 30) {
152
+ return this.fetch(
153
+ `/search/issues?q=author:${username}+type:pr&per_page=${limit}&sort=created`,
154
+ );
155
+ }
156
+
157
+ /**
158
+ * Get user's issues across all repos
159
+ * @param {string} username - GitHub username
160
+ * @param {number} [limit] - Number of issues to fetch
161
+ * @returns {Promise<object>} - Search results with issues
162
+ */
163
+ async getUserIssues(username, limit = 30) {
164
+ return this.fetch(
165
+ `/search/issues?q=author:${username}+type:issue&per_page=${limit}&sort=created`,
166
+ );
167
+ }
168
+ }