@forgeportal/plugin-github-insights 1.3.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/.turbo/turbo-build.log +4 -0
- package/LICENSE +21 -0
- package/dist/GitHubInsightsTab.d.ts +8 -0
- package/dist/GitHubInsightsTab.d.ts.map +1 -0
- package/dist/GitHubInsightsTab.js +111 -0
- package/dist/GitHubInsightsTab.js.map +1 -0
- package/dist/__tests__/api-client.test.d.ts +2 -0
- package/dist/__tests__/api-client.test.d.ts.map +1 -0
- package/dist/__tests__/api-client.test.js +159 -0
- package/dist/__tests__/api-client.test.js.map +1 -0
- package/dist/api-client.d.ts +34 -0
- package/dist/api-client.d.ts.map +1 -0
- package/dist/api-client.js +85 -0
- package/dist/api-client.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/routes.d.ts +14 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +115 -0
- package/dist/routes.js.map +1 -0
- package/dist/types.d.ts +83 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/ui.d.ts +11 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +17 -0
- package/dist/ui.js.map +1 -0
- package/forgeportal-plugin.json +23 -0
- package/package.json +50 -0
- package/src/GitHubInsightsTab.tsx +421 -0
- package/src/__tests__/api-client.test.ts +192 -0
- package/src/api-client.ts +120 -0
- package/src/index.ts +42 -0
- package/src/routes.ts +151 -0
- package/src/types.ts +86 -0
- package/src/ui.ts +18 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GHRepo,
|
|
3
|
+
GHPR,
|
|
4
|
+
GHCommit,
|
|
5
|
+
GHContributor,
|
|
6
|
+
GHWorkflowRun,
|
|
7
|
+
GitHubInsightsConfig,
|
|
8
|
+
CacheEntry,
|
|
9
|
+
} from './types.js';
|
|
10
|
+
|
|
11
|
+
const GITHUB_API = 'https://api.github.com';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Minimal GitHub REST API client with in-process TTL cache.
|
|
15
|
+
* Uses the standard GitHub token from config (falls back to SCM_GITHUB_TOKEN).
|
|
16
|
+
* Respects Retry-After headers to avoid secondary rate limit penalties.
|
|
17
|
+
*/
|
|
18
|
+
export class GitHubInsightsClient {
|
|
19
|
+
private readonly token: string;
|
|
20
|
+
private readonly ttlMs: number;
|
|
21
|
+
private readonly cache = new Map<string, CacheEntry<unknown>>();
|
|
22
|
+
|
|
23
|
+
constructor(config: GitHubInsightsConfig) {
|
|
24
|
+
this.token = config.token;
|
|
25
|
+
this.ttlMs = config.cacheTTLSeconds * 1000;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private async get<T>(path: string): Promise<T> {
|
|
29
|
+
const cacheKey = path;
|
|
30
|
+
|
|
31
|
+
if (this.ttlMs > 0) {
|
|
32
|
+
const hit = this.cache.get(cacheKey);
|
|
33
|
+
if (hit && Date.now() < hit.expiresAt) return hit.data as T;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const res = await (fetch as (url: string, init: Record<string, unknown>) => Promise<Response>)(
|
|
37
|
+
`${GITHUB_API}${path}`,
|
|
38
|
+
{
|
|
39
|
+
headers: {
|
|
40
|
+
Authorization: `Bearer ${this.token}`,
|
|
41
|
+
Accept: 'application/vnd.github+json',
|
|
42
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
if (res.status === 403 || res.status === 429) {
|
|
48
|
+
const retryAfter = res.headers.get('Retry-After');
|
|
49
|
+
throw new Error(
|
|
50
|
+
`GitHub rate limit hit (${res.status}).${retryAfter ? ` Retry after ${retryAfter}s.` : ''}`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (res.status === 404) {
|
|
55
|
+
throw new Error(`GitHub resource not found: ${path}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
const body = await res.text().catch(() => '');
|
|
60
|
+
throw new Error(`GitHub API ${res.status}: ${body || res.statusText}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const data = await res.json() as T;
|
|
64
|
+
|
|
65
|
+
if (this.ttlMs > 0) {
|
|
66
|
+
this.cache.set(cacheKey, { data, expiresAt: Date.now() + this.ttlMs });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return data;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** GET /repos/{owner}/{repo} */
|
|
73
|
+
getRepo(owner: string, repo: string): Promise<GHRepo> {
|
|
74
|
+
return this.get<GHRepo>(`/repos/${owner}/${repo}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** GET /repos/{owner}/{repo}/pulls?state=open&per_page=25 */
|
|
78
|
+
getOpenPRs(owner: string, repo: string): Promise<GHPR[]> {
|
|
79
|
+
return this.get<GHPR[]>(
|
|
80
|
+
`/repos/${owner}/${repo}/pulls?state=open&per_page=25&sort=updated&direction=desc`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** GET /repos/{owner}/{repo}/commits?per_page=20 */
|
|
85
|
+
getRecentCommits(owner: string, repo: string): Promise<GHCommit[]> {
|
|
86
|
+
return this.get<GHCommit[]>(
|
|
87
|
+
`/repos/${owner}/${repo}/commits?per_page=20`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** GET /repos/{owner}/{repo}/contributors?per_page=10&anon=false */
|
|
92
|
+
getContributors(owner: string, repo: string): Promise<GHContributor[]> {
|
|
93
|
+
return this.get<GHContributor[]>(
|
|
94
|
+
`/repos/${owner}/${repo}/contributors?per_page=10&anon=false`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** GET /repos/{owner}/{repo}/actions/runs?per_page=10 */
|
|
99
|
+
getWorkflowRuns(owner: string, repo: string): Promise<{ workflow_runs: GHWorkflowRun[] }> {
|
|
100
|
+
return this.get<{ workflow_runs: GHWorkflowRun[] }>(
|
|
101
|
+
`/repos/${owner}/${repo}/actions/runs?per_page=10`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parses a GitHub repo URL and returns { owner, repo } or null.
|
|
108
|
+
* Handles: https://github.com/owner/repo and github.com/owner/repo
|
|
109
|
+
*/
|
|
110
|
+
export function parseGitHubUrl(url: string): { owner: string; repo: string } | null {
|
|
111
|
+
try {
|
|
112
|
+
const parsed = new URL(url.startsWith('http') ? url : `https://${url}`);
|
|
113
|
+
if (!parsed.hostname.endsWith('github.com')) return null;
|
|
114
|
+
const parts = parsed.pathname.replace(/^\//, '').replace(/\.git$/, '').split('/');
|
|
115
|
+
if (parts.length < 2 || !parts[0] || !parts[1]) return null;
|
|
116
|
+
return { owner: parts[0], repo: parts[1] };
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ForgeBackendPluginSDK } from '@forgeportal/plugin-sdk';
|
|
2
|
+
import type { GitHubInsightsConfig } from './types.js';
|
|
3
|
+
import { createRoutes } from './routes.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Backend entry point for the GitHub Insights plugin.
|
|
7
|
+
* Called by the ForgePortal plugin loader at startup.
|
|
8
|
+
*
|
|
9
|
+
* Configuration (forgeportal.yaml -> plugins.github-insights.config):
|
|
10
|
+
* cacheTTLSeconds: number (default: 300)
|
|
11
|
+
*
|
|
12
|
+
* Token resolution (first match wins):
|
|
13
|
+
* 1. FORGEPORTAL_PLUGIN_GITHUB_INSIGHTS_TOKEN env var (dedicated token)
|
|
14
|
+
* 2. SCM_GITHUB_TOKEN env var (shared SCM token)
|
|
15
|
+
*/
|
|
16
|
+
export function registerBackendPlugin(sdk: ForgeBackendPluginSDK): void {
|
|
17
|
+
const token =
|
|
18
|
+
process.env['FORGEPORTAL_PLUGIN_GITHUB_INSIGHTS_TOKEN'] ??
|
|
19
|
+
process.env['SCM_GITHUB_TOKEN'] ??
|
|
20
|
+
'';
|
|
21
|
+
|
|
22
|
+
const cacheTTLSeconds = sdk.config.get<number>('cacheTTLSeconds') ?? 300;
|
|
23
|
+
|
|
24
|
+
if (!token) {
|
|
25
|
+
sdk.logger.warn(
|
|
26
|
+
'github-insights plugin: no GitHub token found. ' +
|
|
27
|
+
'Set FORGEPORTAL_PLUGIN_GITHUB_INSIGHTS_TOKEN or SCM_GITHUB_TOKEN. ' +
|
|
28
|
+
'API calls will fail for private repos.',
|
|
29
|
+
);
|
|
30
|
+
} else {
|
|
31
|
+
sdk.logger.info(
|
|
32
|
+
`github-insights plugin: ready (cache TTL: ${cacheTTLSeconds}s)`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const config: GitHubInsightsConfig = { token, cacheTTLSeconds };
|
|
37
|
+
|
|
38
|
+
sdk.registerBackendRoute({
|
|
39
|
+
path: '',
|
|
40
|
+
handler: createRoutes(config),
|
|
41
|
+
});
|
|
42
|
+
}
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
|
2
|
+
import { GitHubInsightsClient } from './api-client.js';
|
|
3
|
+
import type { GitHubInsightsConfig } from './types.js';
|
|
4
|
+
|
|
5
|
+
interface EntityParams { entityId: string }
|
|
6
|
+
interface RepoQuery { owner: string; repo: string }
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates Fastify route handlers for the GitHub Insights plugin.
|
|
10
|
+
* All routes mounted under /api/v1/plugins/github-insights/ by the plugin loader.
|
|
11
|
+
*
|
|
12
|
+
* Routes:
|
|
13
|
+
* GET entities/:entityId/overview — repo info + open PR count + latest commit
|
|
14
|
+
* GET entities/:entityId/prs — paginated open PRs
|
|
15
|
+
* GET entities/:entityId/commits — last 20 commits
|
|
16
|
+
* GET entities/:entityId/contributors — top contributors
|
|
17
|
+
*/
|
|
18
|
+
export function createRoutes(config: GitHubInsightsConfig) {
|
|
19
|
+
const client = new GitHubInsightsClient(config);
|
|
20
|
+
|
|
21
|
+
return async function handler(fastify: FastifyInstance): Promise<void> {
|
|
22
|
+
function requireOwnerRepo(
|
|
23
|
+
request: FastifyRequest<{ Params: EntityParams; Querystring: RepoQuery }>,
|
|
24
|
+
reply: FastifyReply,
|
|
25
|
+
): { owner: string; repo: string } | null {
|
|
26
|
+
const { owner, repo } = request.query;
|
|
27
|
+
if (!owner || !repo) {
|
|
28
|
+
void reply.status(400).send({
|
|
29
|
+
error: 'Bad Request',
|
|
30
|
+
message: 'Query parameters "owner" and "repo" are required.',
|
|
31
|
+
});
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return { owner, repo };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* GET /entities/:entityId/overview?owner=&repo=
|
|
39
|
+
* Returns: repo metadata + open PR count + latest commit.
|
|
40
|
+
*/
|
|
41
|
+
fastify.get(
|
|
42
|
+
'entities/:entityId/overview',
|
|
43
|
+
async (
|
|
44
|
+
request: FastifyRequest<{ Params: EntityParams; Querystring: RepoQuery }>,
|
|
45
|
+
reply: FastifyReply,
|
|
46
|
+
) => {
|
|
47
|
+
const ref = requireOwnerRepo(request, reply);
|
|
48
|
+
if (!ref) return;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const [repoData, prs, commits] = await Promise.allSettled([
|
|
52
|
+
client.getRepo(ref.owner, ref.repo),
|
|
53
|
+
client.getOpenPRs(ref.owner, ref.repo),
|
|
54
|
+
client.getRecentCommits(ref.owner, ref.repo),
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const repo = repoData.status === 'fulfilled' ? repoData.value : null;
|
|
58
|
+
const openPRCount = prs.status === 'fulfilled' ? prs.value.length : 0;
|
|
59
|
+
const latestCommit = commits.status === 'fulfilled' ? (commits.value[0] ?? null) : null;
|
|
60
|
+
|
|
61
|
+
if (!repo) {
|
|
62
|
+
const err = repoData.status === 'rejected' ? repoData.reason : new Error('Unknown');
|
|
63
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
64
|
+
if (message.includes('not found')) {
|
|
65
|
+
return reply.status(404).send({ error: 'Not Found', message });
|
|
66
|
+
}
|
|
67
|
+
return reply.status(502).send({ error: 'Bad Gateway', message });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return reply.send({ data: { repo, openPRCount, latestCommit } });
|
|
71
|
+
} catch (err) {
|
|
72
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
73
|
+
request.log.error({ err }, 'github-insights: getOverview failed');
|
|
74
|
+
return reply.status(502).send({ error: 'Bad Gateway', message });
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* GET /entities/:entityId/prs?owner=&repo=
|
|
81
|
+
* Returns up to 25 open pull requests.
|
|
82
|
+
*/
|
|
83
|
+
fastify.get(
|
|
84
|
+
'entities/:entityId/prs',
|
|
85
|
+
async (
|
|
86
|
+
request: FastifyRequest<{ Params: EntityParams; Querystring: RepoQuery }>,
|
|
87
|
+
reply: FastifyReply,
|
|
88
|
+
) => {
|
|
89
|
+
const ref = requireOwnerRepo(request, reply);
|
|
90
|
+
if (!ref) return;
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const prs = await client.getOpenPRs(ref.owner, ref.repo);
|
|
94
|
+
return reply.send({ data: prs });
|
|
95
|
+
} catch (err) {
|
|
96
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
97
|
+
request.log.error({ err }, 'github-insights: getPRs failed');
|
|
98
|
+
return reply.status(502).send({ error: 'Bad Gateway', message });
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* GET /entities/:entityId/commits?owner=&repo=
|
|
105
|
+
* Returns the last 20 commits.
|
|
106
|
+
*/
|
|
107
|
+
fastify.get(
|
|
108
|
+
'entities/:entityId/commits',
|
|
109
|
+
async (
|
|
110
|
+
request: FastifyRequest<{ Params: EntityParams; Querystring: RepoQuery }>,
|
|
111
|
+
reply: FastifyReply,
|
|
112
|
+
) => {
|
|
113
|
+
const ref = requireOwnerRepo(request, reply);
|
|
114
|
+
if (!ref) return;
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const commits = await client.getRecentCommits(ref.owner, ref.repo);
|
|
118
|
+
return reply.send({ data: commits });
|
|
119
|
+
} catch (err) {
|
|
120
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
121
|
+
request.log.error({ err }, 'github-insights: getCommits failed');
|
|
122
|
+
return reply.status(502).send({ error: 'Bad Gateway', message });
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* GET /entities/:entityId/contributors?owner=&repo=
|
|
129
|
+
* Returns top contributors by commit count.
|
|
130
|
+
*/
|
|
131
|
+
fastify.get(
|
|
132
|
+
'entities/:entityId/contributors',
|
|
133
|
+
async (
|
|
134
|
+
request: FastifyRequest<{ Params: EntityParams; Querystring: RepoQuery }>,
|
|
135
|
+
reply: FastifyReply,
|
|
136
|
+
) => {
|
|
137
|
+
const ref = requireOwnerRepo(request, reply);
|
|
138
|
+
if (!ref) return;
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const contributors = await client.getContributors(ref.owner, ref.repo);
|
|
142
|
+
return reply.send({ data: contributors.slice(0, 5) });
|
|
143
|
+
} catch (err) {
|
|
144
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
145
|
+
request.log.error({ err }, 'github-insights: getContributors failed');
|
|
146
|
+
return reply.status(502).send({ error: 'Bad Gateway', message });
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
};
|
|
151
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// ─── GitHub API response shapes (minimal subset) ────────────────────────────
|
|
2
|
+
|
|
3
|
+
export interface GHRepo {
|
|
4
|
+
full_name: string;
|
|
5
|
+
description: string | null;
|
|
6
|
+
default_branch: string;
|
|
7
|
+
language: string | null;
|
|
8
|
+
stargazers_count: number;
|
|
9
|
+
forks_count: number;
|
|
10
|
+
open_issues_count: number;
|
|
11
|
+
html_url: string;
|
|
12
|
+
private: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface GHPRLabel { name: string; color: string }
|
|
16
|
+
|
|
17
|
+
export interface GHPR {
|
|
18
|
+
number: number;
|
|
19
|
+
title: string;
|
|
20
|
+
html_url: string;
|
|
21
|
+
state: string;
|
|
22
|
+
created_at: string;
|
|
23
|
+
updated_at: string;
|
|
24
|
+
user: { login: string; avatar_url: string };
|
|
25
|
+
labels: GHPRLabel[];
|
|
26
|
+
draft: boolean;
|
|
27
|
+
head: { ref: string };
|
|
28
|
+
base: { ref: string };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface GHCommitAuthor {
|
|
32
|
+
name: string;
|
|
33
|
+
email: string;
|
|
34
|
+
date: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface GHCommit {
|
|
38
|
+
sha: string;
|
|
39
|
+
html_url: string;
|
|
40
|
+
commit: {
|
|
41
|
+
message: string;
|
|
42
|
+
author: GHCommitAuthor;
|
|
43
|
+
committer: GHCommitAuthor;
|
|
44
|
+
};
|
|
45
|
+
author: { login: string; avatar_url: string } | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface GHContributor {
|
|
49
|
+
login: string;
|
|
50
|
+
avatar_url: string;
|
|
51
|
+
html_url: string;
|
|
52
|
+
contributions: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface GHWorkflowRun {
|
|
56
|
+
id: number;
|
|
57
|
+
name: string | null;
|
|
58
|
+
display_title: string;
|
|
59
|
+
status: string | null;
|
|
60
|
+
conclusion: string | null;
|
|
61
|
+
html_url: string;
|
|
62
|
+
created_at: string;
|
|
63
|
+
head_branch: string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── Aggregated overview ─────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
export interface GitHubOverview {
|
|
69
|
+
repo: GHRepo;
|
|
70
|
+
openPRCount: number;
|
|
71
|
+
latestCommit: GHCommit | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── Plugin config ────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
export interface GitHubInsightsConfig {
|
|
77
|
+
token: string;
|
|
78
|
+
cacheTTLSeconds: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Cache entry ─────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
export interface CacheEntry<T> {
|
|
84
|
+
data: T;
|
|
85
|
+
expiresAt: number;
|
|
86
|
+
}
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ForgePluginSDK } from '@forgeportal/plugin-sdk';
|
|
2
|
+
import { GitHubInsightsTab } from './GitHubInsightsTab.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* UI entry point for the GitHub Insights plugin.
|
|
6
|
+
* Called by the ForgePortal UI shell at startup.
|
|
7
|
+
*
|
|
8
|
+
* Registration in apps/ui/src/plugins/index.ts:
|
|
9
|
+
* import { registerPlugin as registerGitHubInsights } from '@forgeportal/plugin-github-insights/ui';
|
|
10
|
+
* registerPluginById('github-insights', registerGitHubInsights);
|
|
11
|
+
*/
|
|
12
|
+
export function registerPlugin(sdk: ForgePluginSDK): void {
|
|
13
|
+
sdk.registerEntityTab({
|
|
14
|
+
id: 'github-insights-tab',
|
|
15
|
+
title: 'GitHub',
|
|
16
|
+
component: GitHubInsightsTab,
|
|
17
|
+
});
|
|
18
|
+
}
|