devvami 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.
- package/LICENSE +21 -0
- package/README.md +255 -0
- package/bin/dev.cmd +3 -0
- package/bin/dev.js +5 -0
- package/bin/run.cmd +3 -0
- package/bin/run.js +5 -0
- package/oclif.manifest.json +1238 -0
- package/package.json +161 -0
- package/src/commands/auth/login.js +89 -0
- package/src/commands/changelog.js +102 -0
- package/src/commands/costs/get.js +73 -0
- package/src/commands/create/repo.js +196 -0
- package/src/commands/docs/list.js +110 -0
- package/src/commands/docs/projects.js +92 -0
- package/src/commands/docs/read.js +172 -0
- package/src/commands/docs/search.js +103 -0
- package/src/commands/doctor.js +115 -0
- package/src/commands/init.js +222 -0
- package/src/commands/open.js +75 -0
- package/src/commands/pipeline/logs.js +41 -0
- package/src/commands/pipeline/rerun.js +66 -0
- package/src/commands/pipeline/status.js +62 -0
- package/src/commands/pr/create.js +114 -0
- package/src/commands/pr/detail.js +83 -0
- package/src/commands/pr/review.js +51 -0
- package/src/commands/pr/status.js +70 -0
- package/src/commands/repo/list.js +113 -0
- package/src/commands/search.js +62 -0
- package/src/commands/tasks/assigned.js +131 -0
- package/src/commands/tasks/list.js +133 -0
- package/src/commands/tasks/today.js +73 -0
- package/src/commands/upgrade.js +52 -0
- package/src/commands/whoami.js +85 -0
- package/src/formatters/cost.js +54 -0
- package/src/formatters/markdown.js +108 -0
- package/src/formatters/openapi.js +146 -0
- package/src/formatters/status.js +48 -0
- package/src/formatters/table.js +87 -0
- package/src/help.js +312 -0
- package/src/hooks/init.js +9 -0
- package/src/hooks/postrun.js +18 -0
- package/src/index.js +1 -0
- package/src/services/auth.js +83 -0
- package/src/services/aws-costs.js +80 -0
- package/src/services/clickup.js +288 -0
- package/src/services/config.js +59 -0
- package/src/services/docs.js +210 -0
- package/src/services/github.js +377 -0
- package/src/services/platform.js +48 -0
- package/src/services/shell.js +42 -0
- package/src/services/version-check.js +58 -0
- package/src/types.js +228 -0
- package/src/utils/banner.js +48 -0
- package/src/utils/errors.js +61 -0
- package/src/utils/gradient.js +130 -0
- package/src/utils/open-browser.js +29 -0
- package/src/utils/typewriter.js +48 -0
- package/src/validators/repo-name.js +42 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import http from 'node:http'
|
|
2
|
+
import { openBrowser } from '../utils/open-browser.js'
|
|
3
|
+
import { loadConfig } from './config.js'
|
|
4
|
+
|
|
5
|
+
/** @import { ClickUpTask } from '../types.js' */
|
|
6
|
+
|
|
7
|
+
const API_BASE = process.env.CLICKUP_API_BASE ?? 'https://api.clickup.com/api/v2'
|
|
8
|
+
const TOKEN_KEY = 'clickup_token'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Format a Date as a local YYYY-MM-DD string (avoids UTC offset issues).
|
|
12
|
+
* @param {Date} date
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
function localDateString(date) {
|
|
16
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get stored ClickUp OAuth token.
|
|
21
|
+
* Reads from (in order): CLICKUP_TOKEN env var, OS keychain, config file.
|
|
22
|
+
* @returns {Promise<string|null>}
|
|
23
|
+
*/
|
|
24
|
+
async function getToken() {
|
|
25
|
+
// Allow tests / CI to inject a token via environment variable
|
|
26
|
+
if (process.env.CLICKUP_TOKEN) return process.env.CLICKUP_TOKEN
|
|
27
|
+
try {
|
|
28
|
+
const { default: keytar } = await import('keytar')
|
|
29
|
+
return keytar.getPassword('devvami', TOKEN_KEY)
|
|
30
|
+
} catch {
|
|
31
|
+
// keytar not available (e.g. WSL2 without D-Bus) — fallback to config
|
|
32
|
+
const config = await loadConfig()
|
|
33
|
+
return config.clickup?.token ?? null
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Store ClickUp token securely (works for both OAuth and Personal API Tokens).
|
|
39
|
+
* @param {string} token
|
|
40
|
+
* @returns {Promise<void>}
|
|
41
|
+
*/
|
|
42
|
+
export async function storeToken(token) {
|
|
43
|
+
try {
|
|
44
|
+
const { default: keytar } = await import('keytar')
|
|
45
|
+
await keytar.setPassword('devvami', TOKEN_KEY, token)
|
|
46
|
+
} catch {
|
|
47
|
+
// Fallback: store in config (less secure)
|
|
48
|
+
const config = await loadConfig()
|
|
49
|
+
await saveConfig({ ...config, clickup: { ...config.clickup, token } })
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Run the ClickUp OAuth localhost redirect flow.
|
|
55
|
+
* @param {string} clientId
|
|
56
|
+
* @param {string} clientSecret
|
|
57
|
+
* @returns {Promise<string>} Access token
|
|
58
|
+
*/
|
|
59
|
+
export async function oauthFlow(clientId, clientSecret) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const server = http.createServer(async (req, res) => {
|
|
62
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
63
|
+
const code = url.searchParams.get('code')
|
|
64
|
+
if (!code) return
|
|
65
|
+
res.end('Authorization successful! You can close this tab.')
|
|
66
|
+
server.close()
|
|
67
|
+
try {
|
|
68
|
+
const resp = await fetch(`${API_BASE}/oauth/token`, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: { 'Content-Type': 'application/json' },
|
|
71
|
+
body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, code }),
|
|
72
|
+
})
|
|
73
|
+
const data = /** @type {any} */ (await resp.json())
|
|
74
|
+
await storeToken(data.access_token)
|
|
75
|
+
resolve(data.access_token)
|
|
76
|
+
} catch (err) {
|
|
77
|
+
reject(err)
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
server.listen(0, async () => {
|
|
81
|
+
const addr = /** @type {import('node:net').AddressInfo} */ (server.address())
|
|
82
|
+
const callbackUrl = `http://localhost:${addr.port}/callback`
|
|
83
|
+
const authUrl = `https://app.clickup.com/api?client_id=${clientId}&redirect_uri=${encodeURIComponent(callbackUrl)}`
|
|
84
|
+
await openBrowser(authUrl)
|
|
85
|
+
})
|
|
86
|
+
server.on('error', reject)
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Make an authenticated request to the ClickUp API.
|
|
92
|
+
* @param {string} path
|
|
93
|
+
* @returns {Promise<unknown>}
|
|
94
|
+
*/
|
|
95
|
+
async function clickupFetch(path) {
|
|
96
|
+
const token = await getToken()
|
|
97
|
+
if (!token) throw new Error('ClickUp not authenticated. Run `dvmi init` to authorize.')
|
|
98
|
+
const resp = await fetch(`${API_BASE}${path}`, {
|
|
99
|
+
headers: { Authorization: token },
|
|
100
|
+
})
|
|
101
|
+
if (resp.status === 429) {
|
|
102
|
+
const reset = Number(resp.headers.get('X-RateLimit-Reset') ?? Date.now() + 1000)
|
|
103
|
+
await new Promise((r) => setTimeout(r, Math.max(reset - Date.now(), 1000)))
|
|
104
|
+
return clickupFetch(path)
|
|
105
|
+
}
|
|
106
|
+
if (!resp.ok) {
|
|
107
|
+
const body = /** @type {any} */ (await resp.json().catch(() => ({})))
|
|
108
|
+
throw new Error(`ClickUp API ${resp.status}: ${body.err ?? resp.statusText}`)
|
|
109
|
+
}
|
|
110
|
+
return resp.json()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get ClickUp user info (used to get user ID for filtering tasks).
|
|
115
|
+
* @returns {Promise<{ id: string, username: string }>}
|
|
116
|
+
*/
|
|
117
|
+
export async function getUser() {
|
|
118
|
+
const data = /** @type {any} */ (await clickupFetch('/user'))
|
|
119
|
+
return { id: String(data.user.id), username: data.user.username }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Map a raw ClickUp API task object to the normalized ClickUpTask shape.
|
|
124
|
+
* @param {any} t - Raw task object from the ClickUp API response
|
|
125
|
+
* @returns {ClickUpTask}
|
|
126
|
+
*/
|
|
127
|
+
function mapTask(t) {
|
|
128
|
+
const folderHidden = t.folder?.hidden === true
|
|
129
|
+
return {
|
|
130
|
+
id: t.id,
|
|
131
|
+
name: t.name,
|
|
132
|
+
status: t.status?.status ?? '',
|
|
133
|
+
statusType: t.status?.type ?? 'open',
|
|
134
|
+
priority: t.priority?.id ? Number(t.priority.id) : 3,
|
|
135
|
+
startDate: t.start_date ? localDateString(new Date(Number(t.start_date))) : null,
|
|
136
|
+
dueDate: t.due_date ? localDateString(new Date(Number(t.due_date))) : null,
|
|
137
|
+
url: t.url,
|
|
138
|
+
assignees: (t.assignees ?? []).map((a) => a.username),
|
|
139
|
+
listId: t.list?.id ?? null,
|
|
140
|
+
listName: t.list?.name ?? null,
|
|
141
|
+
folderId: folderHidden ? null : (t.folder?.id ?? null),
|
|
142
|
+
folderName: folderHidden ? null : (t.folder?.name ?? null),
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Get tasks assigned to the current user, with automatic pagination.
|
|
148
|
+
* @param {string} teamId - ClickUp workspace/team ID
|
|
149
|
+
* @param {{ status?: string, due_date_lt?: number }} [filters]
|
|
150
|
+
* @param {((count: number) => void)} [onProgress] - Called after each page with cumulative task count
|
|
151
|
+
* @returns {Promise<ClickUpTask[]>}
|
|
152
|
+
*/
|
|
153
|
+
export async function getTasks(teamId, filters = {}, onProgress) {
|
|
154
|
+
let basePath = `/team/${teamId}/task?assignees[]=${(await getUser()).id}`
|
|
155
|
+
if (filters.status) basePath += `&statuses[]=${encodeURIComponent(filters.status)}`
|
|
156
|
+
if (filters.due_date_lt != null) basePath += `&due_date_lt=${filters.due_date_lt}`
|
|
157
|
+
|
|
158
|
+
let page = 0
|
|
159
|
+
/** @type {ClickUpTask[]} */
|
|
160
|
+
const allTasks = []
|
|
161
|
+
let hasMore = true
|
|
162
|
+
|
|
163
|
+
while (hasMore) {
|
|
164
|
+
const data = /** @type {any} */ (await clickupFetch(`${basePath}&page=${page}`))
|
|
165
|
+
allTasks.push(...data.tasks.map(mapTask))
|
|
166
|
+
hasMore = data.has_more ?? false
|
|
167
|
+
page++
|
|
168
|
+
if (onProgress) onProgress(allTasks.length)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return allTasks
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Get tasks active today: runs two parallel requests (due today/overdue + in progress)
|
|
176
|
+
* and deduplicates by task ID. Excludes tasks whose status type is 'closed'.
|
|
177
|
+
* @param {string} teamId
|
|
178
|
+
* @returns {Promise<ClickUpTask[]>}
|
|
179
|
+
*/
|
|
180
|
+
export async function getTasksToday(teamId) {
|
|
181
|
+
const endOfTodayMs = new Date().setHours(23, 59, 59, 999)
|
|
182
|
+
|
|
183
|
+
const [overdueTasks, inProgressTasks] = await Promise.all([
|
|
184
|
+
getTasks(teamId, { due_date_lt: endOfTodayMs }),
|
|
185
|
+
getTasks(teamId, { status: 'in progress' }),
|
|
186
|
+
])
|
|
187
|
+
|
|
188
|
+
// De-duplicate by task ID (a task may appear in both result sets)
|
|
189
|
+
/** @type {Map<string, ClickUpTask>} */
|
|
190
|
+
const seen = new Map()
|
|
191
|
+
for (const t of [...overdueTasks, ...inProgressTasks]) seen.set(t.id, t)
|
|
192
|
+
const merged = [...seen.values()]
|
|
193
|
+
|
|
194
|
+
const today = localDateString(new Date())
|
|
195
|
+
|
|
196
|
+
return merged.filter((t) => {
|
|
197
|
+
// Exclude tasks that ClickUp considers closed (done/completed regardless of language)
|
|
198
|
+
if (t.statusType === 'closed') return false
|
|
199
|
+
|
|
200
|
+
const start = t.startDate
|
|
201
|
+
const due = t.dueDate
|
|
202
|
+
|
|
203
|
+
// Always include overdue tasks (due date in the past, not closed)
|
|
204
|
+
if (due && due < today) return true
|
|
205
|
+
|
|
206
|
+
// today is within [startDate, dueDate]
|
|
207
|
+
if (start && due) return start <= today && today <= due
|
|
208
|
+
if (start && !due) return start <= today
|
|
209
|
+
// No startDate: include only if due today (overdue already handled above)
|
|
210
|
+
if (!start && due) return today === due
|
|
211
|
+
|
|
212
|
+
// No dates at all: fall back to in-progress status keyword
|
|
213
|
+
const status = t.status?.toLowerCase().replace(/_/g, ' ') ?? ''
|
|
214
|
+
return status.includes('in progress')
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Get tasks from a specific ClickUp list, with automatic pagination.
|
|
220
|
+
* @param {string} listId - ClickUp list ID
|
|
221
|
+
* @param {{ status?: string }} [filters]
|
|
222
|
+
* @param {((count: number) => void)} [onProgress] - Called after each page with cumulative task count
|
|
223
|
+
* @returns {Promise<ClickUpTask[]>}
|
|
224
|
+
* @throws {Error} If the list is not found or not accessible (404)
|
|
225
|
+
*/
|
|
226
|
+
export async function getTasksByList(listId, filters = {}, onProgress) {
|
|
227
|
+
let basePath = `/list/${listId}/task?include_closed=false`
|
|
228
|
+
if (filters.status) basePath += `&statuses[]=${encodeURIComponent(filters.status)}`
|
|
229
|
+
|
|
230
|
+
let page = 0
|
|
231
|
+
/** @type {ClickUpTask[]} */
|
|
232
|
+
const allTasks = []
|
|
233
|
+
let hasMore = true
|
|
234
|
+
|
|
235
|
+
while (hasMore) {
|
|
236
|
+
let data
|
|
237
|
+
try {
|
|
238
|
+
data = /** @type {any} */ (await clickupFetch(`${basePath}&page=${page}`))
|
|
239
|
+
} catch (err) {
|
|
240
|
+
if (err instanceof Error && err.message.includes('404')) {
|
|
241
|
+
throw new Error("Lista non trovata o non accessibile. Verifica l'ID con l'URL della lista in ClickUp.")
|
|
242
|
+
}
|
|
243
|
+
throw err
|
|
244
|
+
}
|
|
245
|
+
allTasks.push(...data.tasks.map(mapTask))
|
|
246
|
+
hasMore = data.has_more ?? false
|
|
247
|
+
page++
|
|
248
|
+
if (onProgress) onProgress(allTasks.length)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return allTasks
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Check if ClickUp is authenticated.
|
|
256
|
+
* @returns {Promise<boolean>}
|
|
257
|
+
*/
|
|
258
|
+
export async function isAuthenticated() {
|
|
259
|
+
const token = await getToken()
|
|
260
|
+
return Boolean(token)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Validate the stored ClickUp token by calling the /user endpoint.
|
|
265
|
+
* Returns { valid: true, user } on success, { valid: false } on 401.
|
|
266
|
+
* @returns {Promise<{ valid: boolean, user?: { id: number, username: string } }>}
|
|
267
|
+
*/
|
|
268
|
+
export async function validateToken() {
|
|
269
|
+
try {
|
|
270
|
+
const data = /** @type {any} */ (await clickupFetch('/user'))
|
|
271
|
+
return { valid: true, user: { id: data.user.id, username: data.user.username } }
|
|
272
|
+
} catch (err) {
|
|
273
|
+
// 401 or no token → not valid
|
|
274
|
+
if (err instanceof Error && (err.message.includes('401') || err.message.includes('not authenticated'))) {
|
|
275
|
+
return { valid: false }
|
|
276
|
+
}
|
|
277
|
+
throw err
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Get the list of ClickUp teams/workspaces accessible with the stored token.
|
|
283
|
+
* @returns {Promise<Array<{ id: string, name: string }>>}
|
|
284
|
+
*/
|
|
285
|
+
export async function getTeams() {
|
|
286
|
+
const data = /** @type {any} */ (await clickupFetch('/team'))
|
|
287
|
+
return (data.teams ?? []).map((t) => ({ id: String(t.id), name: t.name }))
|
|
288
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
|
|
6
|
+
/** @import { CLIConfig } from '../types.js' */
|
|
7
|
+
|
|
8
|
+
const CONFIG_DIR = process.env.XDG_CONFIG_HOME
|
|
9
|
+
? join(process.env.XDG_CONFIG_HOME, 'dvmi')
|
|
10
|
+
: join(homedir(), '.config', 'dvmi')
|
|
11
|
+
|
|
12
|
+
export const CONFIG_PATH = join(CONFIG_DIR, 'config.json')
|
|
13
|
+
|
|
14
|
+
/** @type {CLIConfig} */
|
|
15
|
+
const DEFAULTS = {
|
|
16
|
+
org: '',
|
|
17
|
+
awsProfile: '',
|
|
18
|
+
awsRegion: 'eu-west-1',
|
|
19
|
+
shell: '',
|
|
20
|
+
clickup: {},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Load CLI config from disk. Returns defaults if file doesn't exist.
|
|
25
|
+
* @param {string} [configPath] - Override config path (used in tests)
|
|
26
|
+
* @returns {Promise<CLIConfig>}
|
|
27
|
+
*/
|
|
28
|
+
export async function loadConfig(configPath = process.env.DVMI_CONFIG_PATH ?? CONFIG_PATH) {
|
|
29
|
+
if (!existsSync(configPath)) return { ...DEFAULTS }
|
|
30
|
+
try {
|
|
31
|
+
const raw = await readFile(configPath, 'utf8')
|
|
32
|
+
return { ...DEFAULTS, ...JSON.parse(raw) }
|
|
33
|
+
} catch {
|
|
34
|
+
return { ...DEFAULTS }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Save CLI config to disk, creating directory if needed.
|
|
40
|
+
* @param {CLIConfig} config
|
|
41
|
+
* @param {string} [configPath] - Override config path (used in tests)
|
|
42
|
+
* @returns {Promise<void>}
|
|
43
|
+
*/
|
|
44
|
+
export async function saveConfig(config, configPath = CONFIG_PATH) {
|
|
45
|
+
const dir = configPath.replace(/\/[^/]+$/, '')
|
|
46
|
+
if (!existsSync(dir)) {
|
|
47
|
+
await mkdir(dir, { recursive: true })
|
|
48
|
+
}
|
|
49
|
+
await writeFile(configPath, JSON.stringify(config, null, 2), 'utf8')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Check whether config exists on disk.
|
|
54
|
+
* @param {string} [configPath]
|
|
55
|
+
* @returns {boolean}
|
|
56
|
+
*/
|
|
57
|
+
export function configExists(configPath = CONFIG_PATH) {
|
|
58
|
+
return existsSync(configPath)
|
|
59
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { createOctokit } from './github.js'
|
|
2
|
+
import { exec } from './shell.js'
|
|
3
|
+
import { isOpenApi, isAsyncApi } from '../formatters/openapi.js'
|
|
4
|
+
import { load } from 'js-yaml'
|
|
5
|
+
|
|
6
|
+
/** @import { DocumentEntry, RepoDocsIndex, SearchMatch, DetectedRepo } from '../types.js' */
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Detect GitHub owner and repo from git remote in the current working directory.
|
|
10
|
+
* @returns {Promise<DetectedRepo>}
|
|
11
|
+
*/
|
|
12
|
+
export async function detectCurrentRepo() {
|
|
13
|
+
const result = await exec('git', ['remote', 'get-url', 'origin'])
|
|
14
|
+
if (result.exitCode !== 0) {
|
|
15
|
+
throw new Error('Not in a git repository. Use --repo to specify a repository.')
|
|
16
|
+
}
|
|
17
|
+
const match = result.stdout.match(/github\.com[:/]([^/]+)\/([^/.]+?)(\.git)?$/)
|
|
18
|
+
if (!match) {
|
|
19
|
+
throw new Error('Could not detect GitHub repository from git remote. Use --repo to specify a repository.')
|
|
20
|
+
}
|
|
21
|
+
return { owner: match[1], repo: match[2] }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Classify a tree entry as a DocumentEntry, or null if it is not a doc file.
|
|
26
|
+
* @param {{ path: string, size: number }} entry
|
|
27
|
+
* @returns {DocumentEntry|null}
|
|
28
|
+
*/
|
|
29
|
+
function classifyEntry(entry) {
|
|
30
|
+
const { size } = entry
|
|
31
|
+
const path = entry.path
|
|
32
|
+
if (size === 0) return null
|
|
33
|
+
const name = path.split('/').pop() ?? path
|
|
34
|
+
|
|
35
|
+
if (/^readme\.(md|rst|txt)$/i.test(path)) {
|
|
36
|
+
return { name, path, type: 'readme', size }
|
|
37
|
+
}
|
|
38
|
+
if (/(openapi|swagger)\.(ya?ml|json)$/i.test(path)) {
|
|
39
|
+
return { name, path, type: 'swagger', size }
|
|
40
|
+
}
|
|
41
|
+
if (/asyncapi\.(ya?ml|json)$/i.test(path)) {
|
|
42
|
+
return { name, path, type: 'asyncapi', size }
|
|
43
|
+
}
|
|
44
|
+
if (path.startsWith('docs/') && /\.(md|rst|txt)$/.test(path)) {
|
|
45
|
+
return { name, path, type: 'doc', size }
|
|
46
|
+
}
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Sort DocumentEntry by type priority then path.
|
|
52
|
+
* @param {DocumentEntry} a
|
|
53
|
+
* @param {DocumentEntry} b
|
|
54
|
+
* @returns {number}
|
|
55
|
+
*/
|
|
56
|
+
function sortEntries(a, b) {
|
|
57
|
+
const order = { readme: 0, swagger: 1, asyncapi: 2, doc: 3 }
|
|
58
|
+
const diff = order[a.type] - order[b.type]
|
|
59
|
+
return diff !== 0 ? diff : a.path.localeCompare(b.path)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* List documentation files in a repository using the GitHub Tree API.
|
|
64
|
+
* @param {string} owner
|
|
65
|
+
* @param {string} repo
|
|
66
|
+
* @returns {Promise<DocumentEntry[]>}
|
|
67
|
+
*/
|
|
68
|
+
export async function listDocs(owner, repo) {
|
|
69
|
+
const octokit = await createOctokit()
|
|
70
|
+
|
|
71
|
+
// 1. Get default branch
|
|
72
|
+
const { data: repoData } = await octokit.rest.repos.get({ owner, repo })
|
|
73
|
+
const defaultBranch = repoData.default_branch
|
|
74
|
+
|
|
75
|
+
// 2. Get HEAD SHA
|
|
76
|
+
const { data: ref } = await octokit.rest.git.getRef({
|
|
77
|
+
owner,
|
|
78
|
+
repo,
|
|
79
|
+
ref: `heads/${defaultBranch}`,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// 3. Fetch full recursive tree
|
|
83
|
+
const { data: tree } = await octokit.rest.git.getTree({
|
|
84
|
+
owner,
|
|
85
|
+
repo,
|
|
86
|
+
tree_sha: ref.object.sha,
|
|
87
|
+
recursive: '1',
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
/** @type {DocumentEntry[]} */
|
|
91
|
+
const entries = []
|
|
92
|
+
for (const e of tree.tree) {
|
|
93
|
+
if (e.type !== 'blob') continue
|
|
94
|
+
const entry = classifyEntry({ path: e.path ?? '', size: e.size ?? 0 })
|
|
95
|
+
if (entry) entries.push(entry)
|
|
96
|
+
}
|
|
97
|
+
return entries.sort(sortEntries)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Read a file's raw content from a repository via GitHub Contents API.
|
|
102
|
+
* @param {string} owner
|
|
103
|
+
* @param {string} repo
|
|
104
|
+
* @param {string} path
|
|
105
|
+
* @returns {Promise<string>}
|
|
106
|
+
*/
|
|
107
|
+
export async function readFile(owner, repo, path) {
|
|
108
|
+
const octokit = await createOctokit()
|
|
109
|
+
const { data } = await octokit.rest.repos.getContent({ owner, repo, path })
|
|
110
|
+
if (Array.isArray(data) || data.type !== 'file') {
|
|
111
|
+
throw new Error(`"${path}" is not a file.`)
|
|
112
|
+
}
|
|
113
|
+
return Buffer.from(data.content, 'base64').toString('utf8')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Search documentation files in a repository for a given term.
|
|
118
|
+
* @param {string} owner
|
|
119
|
+
* @param {string} repo
|
|
120
|
+
* @param {string} term
|
|
121
|
+
* @returns {Promise<SearchMatch[]>}
|
|
122
|
+
*/
|
|
123
|
+
export async function searchDocs(owner, repo, term) {
|
|
124
|
+
const entries = await listDocs(owner, repo)
|
|
125
|
+
const q = term.toLowerCase()
|
|
126
|
+
|
|
127
|
+
/** @type {SearchMatch[]} */
|
|
128
|
+
const allMatches = []
|
|
129
|
+
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
let content
|
|
132
|
+
try {
|
|
133
|
+
content = await readFile(owner, repo, entry.path)
|
|
134
|
+
} catch {
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
const lines = content.split('\n')
|
|
138
|
+
let occurrences = 0
|
|
139
|
+
for (let i = 0; i < lines.length; i++) {
|
|
140
|
+
if (lines[i].toLowerCase().includes(q)) {
|
|
141
|
+
occurrences++
|
|
142
|
+
allMatches.push({
|
|
143
|
+
file: entry.path,
|
|
144
|
+
line: i + 1,
|
|
145
|
+
context: lines[i].trim(),
|
|
146
|
+
occurrences: 0, // filled below
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Back-fill occurrences count for all matches from this file
|
|
151
|
+
for (const m of allMatches) {
|
|
152
|
+
if (m.file === entry.path) m.occurrences = occurrences
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return allMatches
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Build a RepoDocsIndex for every repository in an org.
|
|
161
|
+
* @param {string} org
|
|
162
|
+
* @param {string[]} repoNames - List of repo names to scan
|
|
163
|
+
* @returns {Promise<RepoDocsIndex[]>}
|
|
164
|
+
*/
|
|
165
|
+
export async function listProjectsDocs(org, repoNames) {
|
|
166
|
+
/** @type {RepoDocsIndex[]} */
|
|
167
|
+
const indexes = []
|
|
168
|
+
|
|
169
|
+
for (const repoName of repoNames) {
|
|
170
|
+
let entries
|
|
171
|
+
try {
|
|
172
|
+
entries = await listDocs(org, repoName)
|
|
173
|
+
} catch {
|
|
174
|
+
entries = []
|
|
175
|
+
}
|
|
176
|
+
indexes.push({
|
|
177
|
+
repo: repoName,
|
|
178
|
+
hasReadme: entries.some((e) => e.type === 'readme'),
|
|
179
|
+
docsCount: entries.filter((e) => e.type === 'doc').length,
|
|
180
|
+
hasSwagger: entries.some((e) => e.type === 'swagger'),
|
|
181
|
+
hasAsyncApi: entries.some((e) => e.type === 'asyncapi'),
|
|
182
|
+
entries,
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return indexes
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Detect whether a file path is an API spec (swagger or asyncapi).
|
|
191
|
+
* Returns the type or null.
|
|
192
|
+
* @param {string} path
|
|
193
|
+
* @param {string} content
|
|
194
|
+
* @returns {'swagger'|'asyncapi'|null}
|
|
195
|
+
*/
|
|
196
|
+
export function detectApiSpecType(path, content) {
|
|
197
|
+
if (/(openapi|swagger)\.(ya?ml|json)$/i.test(path)) return 'swagger'
|
|
198
|
+
if (/asyncapi\.(ya?ml|json)$/i.test(path)) return 'asyncapi'
|
|
199
|
+
// Try to detect from content
|
|
200
|
+
try {
|
|
201
|
+
const doc = /^\s*\{/.test(content.trim())
|
|
202
|
+
? JSON.parse(content)
|
|
203
|
+
: load(content)
|
|
204
|
+
if (doc && typeof doc === 'object') {
|
|
205
|
+
if (isOpenApi(/** @type {Record<string, unknown>} */ (doc))) return 'swagger'
|
|
206
|
+
if (isAsyncApi(/** @type {Record<string, unknown>} */ (doc))) return 'asyncapi'
|
|
207
|
+
}
|
|
208
|
+
} catch { /* ignore */ }
|
|
209
|
+
return null
|
|
210
|
+
}
|