@goodandready/dsh-github-ops 0.1.2 → 0.2.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/lib/routes.js ADDED
@@ -0,0 +1,222 @@
1
+ // HTTP routes the plugin's own user surfaces read.
2
+ //
3
+ // Three read-only endpoints, each behind the same rules: GET only, a local or same-origin
4
+ // caller only, and no access value anywhere in the payload. They live here so the host half
5
+ // stays a composition: the card, the Source Control tab and the sidebar panel each need one
6
+ // endpoint, and none of them needs a tool.
7
+
8
+ import { existsSync } from 'node:fs'
9
+ import { join as joinPath } from 'node:path'
10
+
11
+ import { statusPayload, isTrustedRequest, sendJson } from './status.js'
12
+ import { parseStatus, parseBranches, parseStashes, parseCount, scmPayload, mergeStateFrom } from './scm.js'
13
+ import { listIssues } from './tools/issues.js'
14
+ import { listRuns } from './tools/actions.js'
15
+ import { listNotifications, repoTreeView } from './tools/panel-data.js'
16
+
17
+ /** Where the settings card reads the access status from. */
18
+ export const STATUS_PATH = '/dsh-github-ops/status'
19
+
20
+ /** Where the Source Control tab reads the working copy from. */
21
+ export const SCM_PATH = '/dsh-github-ops/scm'
22
+
23
+ /** Where the sidebar panel reads its data from (read-only). */
24
+ export const PANEL_PATH = '/dsh-github-ops/panel'
25
+
26
+ /**
27
+ * Register the three routes.
28
+ *
29
+ * @param ctx - the cordis context
30
+ * @param deps - resolveToken, client, liveConfig, describeError, makeGitRunner and cacheStats,
31
+ * injected so this module stays free of the host's own composition.
32
+ */
33
+ export function registerRoutes(ctx, deps) {
34
+ // The card reads the access status from here instead of from a tool result: the model has
35
+ // no business holding the access value, and the payload describes this machine's account,
36
+ // so only a local or same-origin caller is answered.
37
+ ctx.inject(['webServer'], (wctx) => {
38
+ try {
39
+ const register = () => wctx.webServer.register({
40
+ kind: 'exact',
41
+ path: STATUS_PATH,
42
+ handler: async (req, res) => {
43
+ if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
44
+ if (!isTrustedRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
45
+ const access = await resolveToken()
46
+ let identity = null
47
+ let rateLimit = null
48
+ let scopes = ''
49
+ let error = ''
50
+ if (access.value) {
51
+ try {
52
+ const github = await client()
53
+ const answer = await github.get('/user')
54
+ identity = answer.data
55
+ rateLimit = answer.rateLimit
56
+ scopes = typeof github.lastScopes === 'function' ? github.lastScopes() : ''
57
+ } catch (err) {
58
+ error = describeError(err)
59
+ }
60
+ }
61
+ sendJson(res, 200, statusPayload({
62
+ access,
63
+ identity,
64
+ rateLimit,
65
+ scopes,
66
+ cache: deps.cacheStats ? deps.cacheStats() : null,
67
+ interceptLinks: liveConfig().interceptLinks === true,
68
+ error,
69
+ }))
70
+ },
71
+ })
72
+ if (typeof wctx.effect === 'function') wctx.effect(register, 'dsh-github-ops: status route')
73
+ else register()
74
+ } catch (err) {
75
+ ctx.logger?.warn?.('[dsh-github-ops] status route not registered:', err && err.message)
76
+ }
77
+ })
78
+
79
+ // The Source Control tab reads the working copy through here: git is called on the session
80
+ // workspace, and only a local caller is answered. Nothing in this route writes.
81
+ ctx.inject(['webServer'], (wctx) => {
82
+ try {
83
+ const register = () => wctx.webServer.register({
84
+ kind: 'exact',
85
+ path: SCM_PATH,
86
+ handler: async (req, res) => {
87
+ if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
88
+ if (!isTrustedRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
89
+ const git = makeGitRunner()
90
+ const cwd = (typeof ctx.cwd === 'string' && ctx.cwd) || process.cwd()
91
+ const url = new URL(req.url || '/', 'http://localhost')
92
+ const file = url.searchParams.get('file') || ''
93
+ const staged = url.searchParams.get('staged') === '1'
94
+
95
+ if (file) {
96
+ // a single diff, capped: the tab shows a file at a time
97
+ const args = staged ? ['diff', '--cached', '--', file] : ['diff', '--', file]
98
+ const diff = await git(args, { cwd })
99
+ const text = diff.code === 0 ? diff.stdout : diff.stderr
100
+ return sendJson(res, 200, { file, staged, diff: String(text || '').slice(0, 200000) })
101
+ }
102
+
103
+ const branchAnswer = await git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd })
104
+ if (branchAnswer.code !== 0) {
105
+ return sendJson(res, 200, scmPayload({ cwd, error: 'not a git repository (or no commits yet)' }))
106
+ }
107
+ const branch = branchAnswer.stdout.trim()
108
+ const upstreamAnswer = await git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], { cwd })
109
+ const upstream = upstreamAnswer.code === 0 ? upstreamAnswer.stdout.trim() : ''
110
+ let ahead = null
111
+ let behind = null
112
+ if (upstream) {
113
+ const aheadAnswer = await git(['rev-list', '--count', `${upstream}..HEAD`], { cwd })
114
+ const behindAnswer = await git(['rev-list', '--count', `HEAD..${upstream}`], { cwd })
115
+ ahead = aheadAnswer.code === 0 ? parseCount(aheadAnswer.stdout) : null
116
+ behind = behindAnswer.code === 0 ? parseCount(behindAnswer.stdout) : null
117
+ }
118
+ const [statusAnswer, branchesAnswer, tagsAnswer, stashesAnswer] = await Promise.all([
119
+ git(['status', '--porcelain=v1'], { cwd }),
120
+ git(['branch', '--format=%(refname:short)\t%(HEAD)'], { cwd }),
121
+ git(['tag', '--list'], { cwd }),
122
+ git(['stash', 'list', '--format=%gd\t%gs'], { cwd }),
123
+ ])
124
+ const gitDirAnswer = await git(['rev-parse', '--absolute-git-dir'], { cwd })
125
+ const gitDir = gitDirAnswer.code === 0 ? gitDirAnswer.stdout.trim() : joinPath(cwd, '.git')
126
+ const merge = mergeStateFrom({
127
+ mergeHead: existsSync(joinPath(gitDir, 'MERGE_HEAD')),
128
+ rebaseDir: existsSync(joinPath(gitDir, 'rebase-merge')) || existsSync(joinPath(gitDir, 'rebase-apply')),
129
+ cherryPickHead: existsSync(joinPath(gitDir, 'CHERRY_PICK_HEAD')),
130
+ revertHead: existsSync(joinPath(gitDir, 'REVERT_HEAD')),
131
+ })
132
+ sendJson(res, 200, scmPayload({
133
+ cwd,
134
+ branch,
135
+ upstream,
136
+ ahead,
137
+ behind,
138
+ status: parseStatus(statusAnswer.code === 0 ? statusAnswer.stdout : ''),
139
+ branches: parseBranches(branchesAnswer.code === 0 ? branchesAnswer.stdout : ''),
140
+ tags: tagsAnswer.code === 0 ? tagsAnswer.stdout.split('\n').map((t) => t.trim()).filter(Boolean).slice(0, 50) : [],
141
+ stashes: parseStashes(stashesAnswer.code === 0 ? stashesAnswer.stdout : ''),
142
+ merge,
143
+ }))
144
+ },
145
+ })
146
+ if (typeof wctx.effect === 'function') wctx.effect(register, 'dsh-github-ops: scm route')
147
+ else register()
148
+ } catch (err) {
149
+ ctx.logger?.warn?.('[dsh-github-ops] scm route not registered:', err && err.message)
150
+ }
151
+ })
152
+
153
+ // The sidebar panel reads through here: one read-only endpoint with a `what` switch, so the
154
+ // panel needs a single contract instead of one route per screen. Only a local caller is
155
+ // answered, and nothing here writes.
156
+ ctx.inject(['webServer'], (wctx) => {
157
+ try {
158
+ const register = () => wctx.webServer.register({
159
+ kind: 'exact',
160
+ path: PANEL_PATH,
161
+ handler: async (req, res) => {
162
+ if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
163
+ if (!isTrustedRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
164
+ const access = await resolveToken()
165
+ if (!access.value) return sendJson(res, 200, { configured: false, guidance: access.guidance })
166
+ const url = new URL(req.url || '/', 'http://localhost')
167
+ const what = url.searchParams.get('what') || 'repos'
168
+ const repoSpec = url.searchParams.get('repo') || ''
169
+ const [repoOwner, repoName] = repoSpec.split('/')
170
+ const ref = url.searchParams.get('ref') || ''
171
+ const path = url.searchParams.get('path') || ''
172
+ const state = url.searchParams.get('state') || 'open'
173
+ const query = url.searchParams.get('q') || ''
174
+ try {
175
+ const github = await client()
176
+ if (what === 'repos') {
177
+ if (query) {
178
+ const { data } = await github.get('/search/repositories', { query: { q: query, per_page: 20, sort: 'stars' } })
179
+ return sendJson(res, 200, { configured: true, repos: (data.items || []).map((r) => ({ fullName: r.full_name, private: r.private, pushedAt: r.pushed_at, description: r.description || '', stars: r.stargazers_count })) })
180
+ }
181
+ const { data } = await github.get('/user/repos', { query: { per_page: 50, sort: 'pushed', affiliation: 'owner,collaborator,organization_member' } })
182
+ return sendJson(res, 200, { configured: true, repos: (data || []).filter((r) => !r.archived).map((r) => ({ fullName: r.full_name, private: r.private, pushedAt: r.pushed_at, description: r.description || '', stars: r.stargazers_count })) })
183
+ }
184
+ if (!repoOwner || !repoName) return sendJson(res, 200, { configured: true, error: 'a repository is required for this view' })
185
+ const repo = { owner: repoOwner, repo: repoName }
186
+ if (what === 'tree') {
187
+ const view = await repoTreeView(github, { ...repo, ref, path })
188
+ return sendJson(res, 200, { configured: true, ...view })
189
+ }
190
+ if (what === 'blob') {
191
+ const { data } = await github.get(`/repos/${repoOwner}/${repoName}/contents/${path}`, { query: ref ? { ref } : undefined })
192
+ if (Array.isArray(data)) return sendJson(res, 200, { configured: true, entries: data.map((e) => ({ name: e.name, type: e.type, size: e.size, path: e.path })) })
193
+ const encoded = String((data && data.content) || '').replace(/\n/g, '')
194
+ const text = encoded ? Buffer.from(encoded, 'base64').toString('utf8') : ''
195
+ return sendJson(res, 200, { configured: true, path: data.path, size: data.size, text: text.slice(0, 200000), truncated: text.length > 200000 })
196
+ }
197
+ if (what === 'issues' || what === 'pulls') {
198
+ const result = await listIssues(github, { ...repo, state, limit: 30, kind: what === 'pulls' ? 'pr' : 'issue' })
199
+ return sendJson(res, 200, { configured: true, items: result.issues })
200
+ }
201
+ if (what === 'runs') {
202
+ const result = await listRuns(github, { ...repo, limit: 20 })
203
+ return sendJson(res, 200, { configured: true, items: result.runs })
204
+ }
205
+ if (what === 'inbox') {
206
+ const result = await listNotifications(github, { limit: 30 })
207
+ return sendJson(res, 200, { configured: true, ...result })
208
+ }
209
+ return sendJson(res, 200, { configured: true, error: `unknown view "${what}"` })
210
+ } catch (err) {
211
+ return sendJson(res, 200, { configured: true, error: describeError(err) })
212
+ }
213
+ },
214
+ })
215
+ if (typeof wctx.effect === 'function') wctx.effect(register, 'dsh-github-ops: panel route')
216
+ else register()
217
+ } catch (err) {
218
+ ctx.logger?.warn?.('[dsh-github-ops] panel route not registered:', err && err.message)
219
+ }
220
+ })
221
+
222
+ }
package/lib/scm.js ADDED
@@ -0,0 +1,115 @@
1
+ // Reading a working copy, for the Source Control tab.
2
+ //
3
+ // The parsers are pure: they take the text git prints and return a structure. That keeps the
4
+ // interesting part — porcelain status, ahead/behind, groups — testable without a repository,
5
+ // and the host route stays a thin wrapper around `git` calls.
6
+ //
7
+ // Nothing here writes: the tab reads, and a write only happens when the human clicks.
8
+
9
+ /** One entry of `git status --porcelain=v1` (XY PATH, with renames as "R old -> new"). */
10
+ export function parseStatusLine(line) {
11
+ if (!line || line.length < 4) return null
12
+ const index = line[0]
13
+ const worktree = line[1]
14
+ let path = line.slice(3)
15
+ let renamedFrom = ''
16
+ const arrow = path.indexOf(' -> ')
17
+ if (arrow !== -1) {
18
+ renamedFrom = path.slice(0, arrow)
19
+ path = path.slice(arrow + 4)
20
+ }
21
+ if (path.startsWith('"') && path.endsWith('"')) path = path.slice(1, -1)
22
+ return { index, worktree, path, renamedFrom }
23
+ }
24
+
25
+ /** Group a porcelain listing the way the panel shows it. */
26
+ export function parseStatus(text) {
27
+ const lines = String(text || '').split('\n').filter((line) => line.trim().length > 0)
28
+ const staged = []
29
+ const changes = []
30
+ const untracked = []
31
+ const unmerged = []
32
+ for (const line of lines) {
33
+ const entry = parseStatusLine(line)
34
+ if (!entry) continue
35
+ const { index, worktree, path, renamedFrom } = entry
36
+ if (index === '?' && worktree === '?') {
37
+ untracked.push({ path, status: '?', staged: false })
38
+ continue
39
+ }
40
+ // a conflicted file is reported once, as a conflict, not as staged and modified
41
+ const conflict = 'UDUAAD'.includes(index) && 'UDUAAD'.includes(worktree)
42
+ if (conflict) {
43
+ unmerged.push({ path, status: 'U', staged: false })
44
+ continue
45
+ }
46
+ if (index !== ' ' && index !== '?') {
47
+ staged.push({ path, status: index, staged: true, renamedFrom: renamedFrom || undefined })
48
+ }
49
+ if (worktree !== ' ' && worktree !== '?') {
50
+ changes.push({ path, status: worktree, staged: false })
51
+ }
52
+ }
53
+ return { staged, changes, untracked, unmerged, total: staged.length + changes.length + untracked.length + unmerged.length }
54
+ }
55
+
56
+ /** `git branch --format='%(refname:short)\t%(HEAD)'` (or `git branch` output as a fallback). */
57
+ export function parseBranches(text) {
58
+ const lines = String(text || '').split('\n').filter((line) => line.trim().length > 0)
59
+ return lines.map((line) => {
60
+ const [name, head] = line.split('\t')
61
+ const current = head ? head.trim() === '*' : line.trim().startsWith('*')
62
+ return { name: (name || line).replace(/^\*/, '').trim(), current }
63
+ }).filter((branch) => branch.name.length > 0)
64
+ }
65
+
66
+ /** `git stash list --format='%gd\t%gs'` or the default `stash@{0}: WIP on main: …`. */
67
+ export function parseStashes(text) {
68
+ const lines = String(text || '').split('\n').filter((line) => line.trim().length > 0)
69
+ return lines.map((line) => {
70
+ const [ref, ...rest] = line.split('\t')
71
+ if (rest.length) return { ref: ref.trim(), message: rest.join(' ').trim() }
72
+ const colon = line.indexOf(':')
73
+ return colon === -1
74
+ ? { ref: line.trim(), message: '' }
75
+ : { ref: line.slice(0, colon).trim(), message: line.slice(colon + 1).trim() }
76
+ })
77
+ }
78
+
79
+ /** Counts from `git rev-list --count <range>`. */
80
+ export function parseCount(text) {
81
+ const value = Number.parseInt(String(text || '').trim(), 10)
82
+ return Number.isFinite(value) && value >= 0 ? value : 0
83
+ }
84
+
85
+ /**
86
+ * The payload the tab renders. Everything is optional: a repository without an upstream has no
87
+ * ahead/behind, and that is reported as such rather than as an error.
88
+ */
89
+ export function scmPayload({
90
+ cwd, branch, upstream, ahead, behind, status, branches, tags, stashes, merge, error,
91
+ } = {}) {
92
+ return {
93
+ cwd: cwd || '',
94
+ isRepository: !error && Boolean(branch),
95
+ branch: branch || '',
96
+ upstream: upstream || '',
97
+ ahead: Number.isFinite(ahead) ? ahead : null,
98
+ behind: Number.isFinite(behind) ? behind : null,
99
+ files: status || { staged: [], changes: [], untracked: [], unmerged: [], total: 0 },
100
+ branches: Array.isArray(branches) ? branches : [],
101
+ tags: Array.isArray(tags) ? tags : [],
102
+ stashes: Array.isArray(stashes) ? stashes : [],
103
+ mergeState: merge || { inProgress: false, kind: '' },
104
+ error: error ? String(error).slice(0, 300) : '',
105
+ }
106
+ }
107
+
108
+ /** Detect a merge or rebase in progress from the presence of the marker files. */
109
+ export function mergeStateFrom({ mergeHead = false, rebaseDir = false, cherryPickHead = false, revertHead = false } = {}) {
110
+ if (mergeHead) return { inProgress: true, kind: 'merge' }
111
+ if (rebaseDir) return { inProgress: true, kind: 'rebase' }
112
+ if (cherryPickHead) return { inProgress: true, kind: 'cherry-pick' }
113
+ if (revertHead) return { inProgress: true, kind: 'revert' }
114
+ return { inProgress: false, kind: '' }
115
+ }
package/lib/status.js ADDED
@@ -0,0 +1,66 @@
1
+ // What the settings card is allowed to know.
2
+ //
3
+ // The status of the access — where it comes from, which account, what the rate limit looks
4
+ // like — belongs in the card, not in a tool result: the model has no business holding a token,
5
+ // and a status block explains "not configured" far better than an error at the first call.
6
+ //
7
+ // The payload never contains the access value. The route answers only a local or same-origin
8
+ // caller, because the payload describes the machine's account.
9
+
10
+ /** Whether a request may read the status payload. Fail-closed. */
11
+ export function isTrustedRequest(req) {
12
+ if (!req) return false
13
+ const address = (req.socket && (req.socket.remoteAddress || req.socket.address?.().address)) || ''
14
+ const loopback = address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
15
+ if (loopback) return true
16
+ const host = header(req, 'host')
17
+ const origin = header(req, 'origin')
18
+ if (!host || !origin) return false
19
+ try {
20
+ return new URL(origin).host === host
21
+ } catch {
22
+ return false
23
+ }
24
+ }
25
+
26
+ function header(req, name) {
27
+ if (!req || !req.headers) return ''
28
+ const value = req.headers[name] || req.headers[name.toLowerCase()]
29
+ return typeof value === 'string' ? value.trim() : ''
30
+ }
31
+
32
+ /**
33
+ * The payload for the card.
34
+ *
35
+ * @param access - result of the access resolution: { value, source, guidance }
36
+ * @param identity - { login, name } or null when the call failed
37
+ * @param rateLimit - { limit, remaining, resetAt } or null
38
+ * @param cache - { size, ttlMs }
39
+ * @param error - a string when the identity call failed
40
+ */
41
+ export function statusPayload({ access, identity, rateLimit, scopes, cache, error } = {}) {
42
+ const configured = Boolean(access && access.value)
43
+ return {
44
+ configured,
45
+ source: configured ? access.source || '' : '',
46
+ login: (identity && identity.login) || '',
47
+ name: (identity && identity.name) || '',
48
+ scopes: scopes || '',
49
+ rateLimit: rateLimit
50
+ ? { limit: rateLimit.limit, remaining: rateLimit.remaining, resetAt: rateLimit.resetAt }
51
+ : null,
52
+ cache: cache ? { size: cache.size, ttlMs: cache.ttlMs } : null,
53
+ guidance: configured ? '' : ((access && access.guidance) || ''),
54
+ error: error ? String(error).slice(0, 200) : '',
55
+ }
56
+ }
57
+
58
+ /** Answers one HTTP request with JSON. Shared by the route so the handler stays thin. */
59
+ export function sendJson(res, statusCode, payload) {
60
+ try {
61
+ res.writeHead(statusCode, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
62
+ res.end(JSON.stringify(payload))
63
+ } catch {
64
+ // a client that went away must not break the route
65
+ }
66
+ }
@@ -38,7 +38,7 @@ export async function listRuns(client, { owner, repo, branch, workflow, status,
38
38
  if (branch) query.branch = branch
39
39
  if (status) query.status = status
40
40
  const { data } = await client.get(route, { query })
41
- const runs = ((data && data.workflow_runs) || []).map(normalizeRun)
41
+ const runs = ((data && data.workflow_runs) || []).map(normalizeRun).slice(0, capped)
42
42
  return { runs, count: runs.length, total: (data && data.total_count) || runs.length }
43
43
  }
44
44
 
@@ -0,0 +1,246 @@
1
+ // Composed reports: the answers a person actually asks for.
2
+ //
3
+ // Each function is a small composition over the plain reads, so the cache in the client
4
+ // makes a report cheap and the pieces stay testable. Nothing here is a verdict on quality:
5
+ // a health score is a transparent heuristic with its evidence attached, and the digest only
6
+ // reports what happened in a window.
7
+
8
+ import { getRepo, normalizeRepo } from './repos.js'
9
+ import { listReleases } from './releases.js'
10
+ import { listIssues } from './issues.js'
11
+ import { listRuns } from './actions.js'
12
+
13
+ function ensureRepo({ owner, repo }) {
14
+ if (!owner || !repo) throw new Error('owner and repo are required')
15
+ }
16
+
17
+ function normalizeCommit(data) {
18
+ return {
19
+ sha: data && data.sha,
20
+ shortSha: data && data.sha ? String(data.sha).slice(0, 7) : '',
21
+ message: data && data.commit && data.commit.message ? String(data.commit.message).split('\n')[0] : '',
22
+ author: (data && data.commit && data.commit.author && data.commit.author.name) || (data && data.author && data.author.login) || '',
23
+ date: (data && data.commit && data.commit.author && data.commit.author.date) || '',
24
+ }
25
+ }
26
+
27
+ /** Recent commits of a branch. */
28
+ export async function listCommits(client, { owner, repo, branch, limit = 10, since } = {}) {
29
+ ensureRepo({ owner, repo })
30
+ const capped = Math.max(1, Math.min(Number(limit) || 10, 100))
31
+ const query = { per_page: capped }
32
+ if (since) query.since = since
33
+ const { data } = await client.get(`/repos/${owner}/${repo}/commits`, {
34
+ query: branch ? { ...query, sha: branch } : query,
35
+ })
36
+ const commits = (Array.isArray(data) ? data : []).map(normalizeCommit).slice(0, capped)
37
+ return { commits, count: commits.length }
38
+ }
39
+
40
+ /** Top contributors by commit count. */
41
+ export async function listContributors(client, { owner, repo, limit = 10 } = {}) {
42
+ ensureRepo({ owner, repo })
43
+ const capped = Math.max(1, Math.min(Number(limit) || 10, 100))
44
+ const { data } = await client.get(`/repos/${owner}/${repo}/contributors`, { query: { per_page: capped } })
45
+ const contributors = (Array.isArray(data) ? data : []).slice(0, capped).map((c) => ({
46
+ login: c.login,
47
+ contributions: c.contributions,
48
+ url: c.html_url,
49
+ }))
50
+ return { contributors, count: contributors.length }
51
+ }
52
+
53
+ /**
54
+ * One call that answers "what is this repository": overview, latest release, open issues,
55
+ * recent commits and top contributors. Composed calls share the client cache, so the whole
56
+ * report costs a handful of requests.
57
+ */
58
+ export async function repoReport(client, { owner, repo, commits: commitLimit = 5, issues: issueLimit = 5, contributors: contributorLimit = 5 } = {}) {
59
+ ensureRepo({ owner, repo })
60
+ const overview = await getRepo(client, { owner, repo })
61
+ const [releases, openIssues, commitList, contributorList] = await Promise.all([
62
+ listReleases(client, { owner, repo, limit: 1 }).catch(() => ({ releases: [] })),
63
+ listIssues(client, { owner, repo, state: 'open', limit: issueLimit }).catch(() => ({ issues: [] })),
64
+ listCommits(client, { owner, repo, branch: overview.defaultBranch, limit: commitLimit }).catch(() => ({ commits: [] })),
65
+ listContributors(client, { owner, repo, limit: contributorLimit }).catch(() => ({ contributors: [] })),
66
+ ])
67
+ return {
68
+ repo: overview.fullName,
69
+ description: overview.description,
70
+ stars: overview.stars,
71
+ forks: overview.forks,
72
+ openIssues: overview.openIssues,
73
+ language: overview.language,
74
+ license: overview.license,
75
+ defaultBranch: overview.defaultBranch,
76
+ pushedAt: overview.pushedAt,
77
+ url: overview.url,
78
+ latestRelease: releases.releases[0] || null,
79
+ issues: openIssues.issues,
80
+ commits: commitList.commits,
81
+ contributors: contributorList.contributors,
82
+ }
83
+ }
84
+
85
+ /** What happened in a repository inside a look-back window. */
86
+ export async function weeklyDigest(client, { owner, repo, days = 7, limit = 10 } = {}) {
87
+ ensureRepo({ owner, repo })
88
+ const window = Math.max(1, Math.min(Number(days) || 7, 90))
89
+ const capped = Math.max(1, Math.min(Number(limit) || 10, 50))
90
+ const since = new Date(Date.now() - window * 24 * 60 * 60 * 1000).toISOString()
91
+
92
+ const [releases, issues, pulls, commits] = await Promise.all([
93
+ listReleases(client, { owner, repo, limit: capped }).catch(() => ({ releases: [] })),
94
+ // pull requests are excluded from "new issues": they are reported separately below
95
+ listIssues(client, { owner, repo, state: 'all', kind: 'issue', limit: capped }).catch(() => ({ issues: [] })),
96
+ listIssues(client, { owner, repo, state: 'all', kind: 'pr', limit: capped }).catch(() => ({ issues: [] })),
97
+ listCommits(client, { owner, repo, limit: capped, since }).catch(() => ({ commits: [] })),
98
+ ])
99
+ const after = (value) => Boolean(value) && value >= since
100
+ return {
101
+ windowDays: window,
102
+ since,
103
+ releases: releases.releases.filter((r) => after(r.publishedAt || r.createdAt)),
104
+ newIssues: issues.issues.filter((i) => after(i.createdAt)),
105
+ mergedPulls: pulls.issues.filter((i) => after(i.closedAt || i.updatedAt)),
106
+ commits: commits.commits,
107
+ }
108
+ }
109
+
110
+ /**
111
+ * The attention queue: mentions, review requests, assignments and threads the token watches.
112
+ * Needs access with notification read permission; a missing scope surfaces as an actionable
113
+ * failure rather than an empty list.
114
+ */
115
+ export async function listNotifications(client, { all = false, participating = false, limit = 20 } = {}) {
116
+ const capped = Math.max(1, Math.min(Number(limit) || 20, 100))
117
+ const query = { per_page: capped }
118
+ if (all) query.all = 'true'
119
+ if (participating) query.participating = 'true'
120
+ const { data } = await client.get('/notifications', { query })
121
+ const items = (Array.isArray(data) ? data : []).slice(0, capped).map((n) => ({
122
+ id: n.id,
123
+ reason: n.reason,
124
+ unread: Boolean(n.unread),
125
+ updatedAt: n.updated_at,
126
+ title: n.subject && n.subject.title,
127
+ type: n.subject && n.subject.type,
128
+ number: n.subject && n.subject.url ? Number(String(n.subject.url).split('/').pop()) : null,
129
+ repository: n.repository ? n.repository.full_name : '',
130
+ }))
131
+ const byReason = {}
132
+ for (const item of items) byReason[item.reason] = (byReason[item.reason] || 0) + 1
133
+ return { count: items.length, byReason, notifications: items }
134
+ }
135
+
136
+ /**
137
+ * A transparent maintenance heuristic. Every point of the score carries its evidence, and the
138
+ * result states plainly that it is not a security audit.
139
+ */
140
+ export function healthOf({ repo, releases = [], issues = [], pulls = [], commits = [] } = {}) {
141
+ const dimensions = []
142
+ const daysSince = (value) => (value ? Math.floor((Date.now() - Date.parse(value)) / (24 * 60 * 60 * 1000)) : null)
143
+
144
+ const pushed = daysSince(repo && repo.pushedAt)
145
+ const activity = pushed === null ? 0 : pushed <= 7 ? 25 : pushed <= 30 ? 18 : pushed <= 90 ? 10 : 3
146
+ dimensions.push({ name: 'activity', score: activity, max: 25, evidence: pushed === null ? 'no push date' : `last push ${pushed} day(s) ago` })
147
+
148
+ const releaseScore = releases.length ? 20 : 8
149
+ dimensions.push({ name: 'releases', score: releaseScore, max: 20, evidence: releases.length ? `latest ${releases[0].tag} (${releases[0].publishedAt || 'unpublished'})` : 'no releases published' })
150
+
151
+ const openCount = Array.isArray(issues) ? issues.length : 0
152
+ const issueScore = openCount === 0 ? 20 : openCount <= 3 ? 15 : openCount <= 10 ? 9 : 4
153
+ dimensions.push({ name: 'issue-load', score: issueScore, max: 20, evidence: `${openCount} open issue(s) in the last page` })
154
+
155
+ const prScore = Array.isArray(pulls) && pulls.length ? 15 : 8
156
+ dimensions.push({ name: 'review-load', score: prScore, max: 15, evidence: Array.isArray(pulls) && pulls.length ? `${pulls.length} recent pull request(s)` : 'no recent pull requests' })
157
+
158
+ const commitCount = Array.isArray(commits) ? commits.length : 0
159
+ const commitScore = commitCount >= 5 ? 20 : commitCount > 0 ? 12 : 4
160
+ dimensions.push({ name: 'commits', score: commitScore, max: 20, evidence: `${commitCount} commit(s) in the last page` })
161
+
162
+ const score = dimensions.reduce((sum, d) => sum + d.score, 0)
163
+ const risks = []
164
+ if (pushed !== null && pushed > 90) risks.push(`no push for ${pushed} days`)
165
+ if (!releases.length) risks.push('no published release')
166
+ if (!(repo && repo.license)) risks.push('no license declared')
167
+ if (!(repo && repo.description)) risks.push('no description')
168
+ if (openCount > 10) risks.push('large open issue backlog')
169
+
170
+ const nextActions = []
171
+ if (!releases.length) nextActions.push('publish the first release')
172
+ if (!(repo && repo.license)) nextActions.push('add a LICENSE')
173
+ if (!(repo && repo.description)) nextActions.push('write a repository description')
174
+ if (pushed !== null && pushed > 90) nextActions.push('push a change or archive the repository')
175
+
176
+ return {
177
+ repo: repo && repo.fullName,
178
+ score,
179
+ max: 100,
180
+ dimensions,
181
+ risks,
182
+ nextActions,
183
+ disclaimer: 'A maintenance heuristic over public signals, not a security audit.',
184
+ }
185
+ }
186
+
187
+ /** Repository health for one repository, gathering its own evidence. */
188
+ export async function repoHealth(client, { owner, repo } = {}) {
189
+ ensureRepo({ owner, repo })
190
+ const [repository, releases, issues, pulls, commits] = await Promise.all([
191
+ getRepo(client, { owner, repo }),
192
+ listReleases(client, { owner, repo, limit: 3 }).catch(() => ({ releases: [] })),
193
+ listIssues(client, { owner, repo, state: 'open', limit: 30 }).catch(() => ({ issues: [] })),
194
+ listIssues(client, { owner, repo, state: 'open', kind: 'pr', limit: 30 }).catch(() => ({ issues: [] })),
195
+ listCommits(client, { owner, repo, limit: 10 }).catch(() => ({ commits: [] })),
196
+ ])
197
+ return healthOf({ repo: repository, releases: releases.releases, issues: issues.issues, pulls: pulls.issues, commits: commits.commits })
198
+ }
199
+
200
+ /** Two repositories side by side, with numeric deltas. */
201
+ export async function compareRepos(client, { first, second } = {}) {
202
+ const a = typeof first === 'string' ? first : ''
203
+ const b = typeof second === 'string' ? second : ''
204
+ if (!a.includes('/') || !b.includes('/')) throw new Error('pass two repositories as "owner/repo"')
205
+ const [left, right] = await Promise.all([a, b].map(async (spec) => {
206
+ const [owner, repo] = spec.split('/')
207
+ return getRepo(client, { owner, repo })
208
+ }))
209
+ const delta = (x, y) => (typeof x === 'number' && typeof y === 'number' ? x - y : null)
210
+ return {
211
+ first: left,
212
+ second: right,
213
+ deltas: {
214
+ stars: delta(left.stars, right.stars),
215
+ forks: delta(left.forks, right.forks),
216
+ openIssues: delta(left.openIssues, right.openIssues),
217
+ },
218
+ }
219
+ }
220
+
221
+ /** Recently created repositories, by stars. Uses the search quota. */
222
+ export async function trendingRepos(client, { language, since, limit = 10 } = {}) {
223
+ const capped = Math.max(1, Math.min(Number(limit) || 10, 50))
224
+ const created = since || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
225
+ const parts = [`created:>${created}`, 'stars:>50']
226
+ if (language) parts.push(`language:${language}`)
227
+ const { data } = await client.get('/search/repositories', {
228
+ query: { q: parts.join(' '), sort: 'stars', order: 'desc', per_page: capped },
229
+ })
230
+ const repos = ((data && data.items) || []).slice(0, capped).map(normalizeRepo)
231
+ return { since: created, count: repos.length, repos }
232
+ }
233
+
234
+ /** A user's repositories by stars. */
235
+ export async function userRepos(client, { login, limit = 10 } = {}) {
236
+ if (!login) throw new Error('login is required')
237
+ const capped = Math.max(1, Math.min(Number(limit) || 10, 100))
238
+ const { data } = await client.get(`/users/${encodeURIComponent(login)}/repos`, {
239
+ query: { sort: 'stars', direction: 'desc', per_page: capped },
240
+ })
241
+ const repos = (Array.isArray(data) ? data : []).slice(0, capped).map(normalizeRepo)
242
+ return { login, count: repos.length, repos }
243
+ }
244
+
245
+ // Re-exported so a caller composing its own report needs one import.
246
+ export { listRuns }