@goodandready/dsh-github-ops 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,72 @@
1
+ // Which repository a call is about.
2
+ //
3
+ // A tool call may name a repository, fall back to the configured default, or — inside a
4
+ // checkout — simply mean "the repository I am in": the origin remote already says which
5
+ // one it is, so demanding it again on every call is noise. Pure functions here; the caller
6
+ // supplies the remote URL, which keeps this testable without a working copy.
7
+
8
+ /** Parse `owner/repo`, `owner/repo#123`, `#123` or `123`. */
9
+ export function parseRef(input) {
10
+ if (typeof input !== 'string') return null
11
+ const text = input.trim()
12
+ if (!text) return null
13
+
14
+ const bare = text.match(/^#?(\d+)$/)
15
+ if (bare) return { owner: '', repo: '', number: Number(bare[1]) }
16
+
17
+ const match = text.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:#(\d+))?$/)
18
+ if (!match) return null
19
+ return { owner: match[1], repo: match[2], number: match[3] ? Number(match[3]) : null }
20
+ }
21
+
22
+ /**
23
+ * Extract `owner/repo` from any git remote URL a GitHub checkout uses:
24
+ * `git@github.com:owner/repo.git`, `ssh://git@github.com/owner/repo.git`,
25
+ * `https://github.com/owner/repo.git`, `https://user@host/owner/repo`.
26
+ * The host is not checked: a mirror remote pointing elsewhere still names a repository.
27
+ */
28
+ export function parseRemoteUrl(url) {
29
+ if (typeof url !== 'string') return null
30
+ const text = url.trim()
31
+ if (!text) return null
32
+
33
+ const scp = text.match(/^[A-Za-z0-9_.-]+@[^:]+:([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/)
34
+ if (scp) return { owner: scp[1], repo: scp[2], number: null, host: text.split(':')[0].split('@')[1] || '' }
35
+
36
+ try {
37
+ const parsed = new URL(text)
38
+ const parts = parsed.pathname.replace(/^\/+/, '').replace(/\.git$/, '').split('/')
39
+ if (parts.length >= 2 && parts[0] && parts[1]) {
40
+ return { owner: parts[0], repo: parts[1], number: null, host: parsed.hostname }
41
+ }
42
+ } catch {
43
+ // not a URL and not an scp-style remote
44
+ }
45
+ return null
46
+ }
47
+
48
+ /**
49
+ * Decide the repository for one call.
50
+ *
51
+ * Order: an explicit reference, then the configured default, then the origin remote of the
52
+ * working copy. `number` (a pull request or issue) is carried through so a caller can pass
53
+ * "123" and keep the repository it was already talking to.
54
+ */
55
+ export function resolveRepoContext({ explicit, fallback, remoteUrl, remotes = {} } = {}) {
56
+ const fromExplicit = parseRef(explicit)
57
+ if (fromExplicit && fromExplicit.owner && fromExplicit.repo) return { ...fromExplicit, source: 'explicit' }
58
+
59
+ const fromFallback = parseRef(fallback)
60
+ if (fromFallback && fromFallback.owner && fromFallback.repo) {
61
+ return { ...fromFallback, number: fromExplicit && fromExplicit.number ? fromExplicit.number : null, source: 'settings' }
62
+ }
63
+
64
+ const fromRemote = parseRemoteUrl(remoteUrl) || parseRemoteUrl(remotes.origin) || parseRemoteUrl(remotes.github)
65
+ if (fromRemote) {
66
+ return { ...fromRemote, number: fromExplicit && fromExplicit.number ? fromExplicit.number : null, source: 'remote' }
67
+ }
68
+ if (fromExplicit && fromExplicit.number) {
69
+ throw new Error('a pull request or issue number needs a repository: pass "owner/repo#N", set a default repository in the plugin settings, or run inside a checkout with an origin remote')
70
+ }
71
+ return null
72
+ }
@@ -0,0 +1,147 @@
1
+ // Deterministic review rules.
2
+ //
3
+ // These are rules, not a model: they point a reader at what needs human attention and are
4
+ // never presented as a verdict on correctness. Every threshold and path pattern is
5
+ // configurable, because what deserves attention differs per repository — a `.gitea/**`
6
+ // directory matters here and nowhere else.
7
+ //
8
+ // Patterns are globs: one without a `/` matches a basename at any depth (`.env` matches
9
+ // `config/.env`), one with a `/` matches the whole repository path (`lib/**/*.js`).
10
+
11
+ export const DEFAULT_RULES = {
12
+ /** Paths that must not change without a human reading the diff. */
13
+ sensitivePaths: [
14
+ '.env', '.env.*', '**/*.pem', '**/*.key', '**/*.p12', '**/*.pfx',
15
+ '.npmrc', '.credentials.yaml', '.credentials.yml',
16
+ 'id_rsa', 'id_ed25519', 'id_ecdsa',
17
+ '**/secrets/**', 'AGENTS.md',
18
+ ],
19
+ sensitiveSeverity: 'critical',
20
+ /** Paths that need attention: infrastructure and internal documents. */
21
+ attentionPaths: [
22
+ '.github/workflows/*', '.gitea/workflows/*', 'docs/**', 'index.md', 'deploy.sh',
23
+ ],
24
+ /** Database migrations. */
25
+ migrationPaths: ['migrations/**', '**/migrations/**', '**/migration/**'],
26
+ /** A source change with no test change is worth a look. */
27
+ testsRequired: true,
28
+ sourcePatterns: ['**/*.js', '**/*.mjs', '**/*.cjs', '**/*.ts', '**/*.tsx', '**/*.py', '**/*.go', '**/*.rs', '**/*.java'],
29
+ testPatterns: ['test/**', 'tests/**', '**/*.test.*', '**/*_test.*', '**/test_*'],
30
+ /** A diff larger than this gets a note. */
31
+ largeDiffLines: 800,
32
+ }
33
+
34
+ function escapeLiteral(ch) {
35
+ return ch.replace(/[.+^${}()|[\]\\]/g, '\\$&')
36
+ }
37
+
38
+ /** Translate one glob into a regular expression. `**` crosses directories, `*` does not. */
39
+ export function globToRegExp(pattern) {
40
+ const text = String(pattern || '').trim()
41
+ const anchored = text.includes('/')
42
+ const prefix = anchored ? '^' : '^(?:.*/)?'
43
+ let source = prefix
44
+ for (let index = 0; index < text.length; index += 1) {
45
+ const ch = text[index]
46
+ if (ch === '*') {
47
+ if (text[index + 1] === '*') {
48
+ // `**/` matches any depth including none
49
+ if (text[index + 2] === '/') {
50
+ source += '(?:.*/)?'
51
+ index += 2
52
+ continue
53
+ }
54
+ source += '.*'
55
+ index += 1
56
+ continue
57
+ }
58
+ source += '[^/]*'
59
+ continue
60
+ }
61
+ if (ch === '?') {
62
+ source += '[^/]'
63
+ continue
64
+ }
65
+ source += escapeLiteral(ch)
66
+ }
67
+ return new RegExp(`${source}$`)
68
+ }
69
+
70
+ /** Whether a repository path matches any of the globs. */
71
+ export function matchesAnyGlob(path, patterns = []) {
72
+ const value = String(path || '')
73
+ return patterns.some((pattern) => globToRegExp(pattern).test(value))
74
+ }
75
+
76
+ /** Normalize a caller-supplied rule set over the defaults. */
77
+ export function normalizeRules(rules) {
78
+ const merged = { ...DEFAULT_RULES, ...(rules && typeof rules === 'object' ? rules : {}) }
79
+ for (const key of ['sensitivePaths', 'attentionPaths', 'migrationPaths', 'sourcePatterns', 'testPatterns']) {
80
+ if (!Array.isArray(merged[key])) merged[key] = DEFAULT_RULES[key]
81
+ }
82
+ if (!['critical', 'attention', 'info'].includes(merged.sensitiveSeverity)) {
83
+ merged.sensitiveSeverity = DEFAULT_RULES.sensitiveSeverity
84
+ }
85
+ if (!Number.isFinite(merged.largeDiffLines) || merged.largeDiffLines <= 0) {
86
+ merged.largeDiffLines = DEFAULT_RULES.largeDiffLines
87
+ }
88
+ return merged
89
+ }
90
+
91
+ /** Top-level areas touched by a diff, used for a one-line summary. */
92
+ export function areasFor(files = []) {
93
+ const areas = new Set()
94
+ for (const file of files) {
95
+ const parts = String(file.filename || file.path || '').split('/')
96
+ areas.add(parts.length > 1 ? parts[0] : (parts[0] || '').replace(/\.[^.]+$/, ''))
97
+ }
98
+ return [...areas].filter(Boolean).sort()
99
+ }
100
+
101
+ /**
102
+ * Findings for one pull request, deterministic and ordered: critical first, then
103
+ * attention, then info, and within a severity by area.
104
+ */
105
+ export function analyzePull({ files = [], rules } = {}) {
106
+ const r = normalizeRules(rules)
107
+ const findings = []
108
+ const paths = files.map((f) => f.filename || f.path || '')
109
+
110
+ const sensitive = paths.filter((p) => matchesAnyGlob(p, r.sensitivePaths))
111
+ if (sensitive.length) {
112
+ findings.push({ level: r.sensitiveSeverity, area: 'sensitive-paths', detail: `paths that need a human eye: ${sensitive.join(', ')}` })
113
+ }
114
+
115
+ const attention = paths.filter((p) => matchesAnyGlob(p, r.attentionPaths))
116
+ if (attention.length) {
117
+ findings.push({ level: 'attention', area: 'attention-paths', detail: `infrastructure or internal documents changed: ${attention.join(', ')}` })
118
+ }
119
+
120
+ const migrations = paths.filter((p) => matchesAnyGlob(p, r.migrationPaths))
121
+ if (migrations.length) {
122
+ findings.push({ level: 'attention', area: 'migration', detail: `database migrations touched: ${migrations.join(', ')}` })
123
+ }
124
+
125
+ if (r.testsRequired) {
126
+ const sources = paths.filter((p) => matchesAnyGlob(p, r.sourcePatterns))
127
+ const tests = paths.filter((p) => matchesAnyGlob(p, r.testPatterns))
128
+ if (sources.length && !tests.length) {
129
+ findings.push({ level: 'attention', area: 'tests', detail: `${sources.length} source file(s) changed with no test change` })
130
+ }
131
+ }
132
+
133
+ const changed = files.reduce((sum, f) => sum + (Number(f.additions) || 0) + (Number(f.deletions) || 0), 0)
134
+ if (changed > r.largeDiffLines) {
135
+ findings.push({ level: 'attention', area: 'size', detail: `large diff: ${changed} changed lines` })
136
+ }
137
+ if (!files.length) findings.push({ level: 'info', area: 'empty', detail: 'no files in the diff' })
138
+
139
+ const rank = { critical: 0, attention: 1, info: 2 }
140
+ return findings.sort((a, b) => (rank[a.level] - rank[b.level]) || a.area.localeCompare(b.area))
141
+ }
142
+
143
+ /** Whether the findings and the CI rollup must block a merge. */
144
+ export function verdictFor({ findings = [], checkRollup = 'unknown' } = {}) {
145
+ const blocking = findings.filter((f) => f.level === 'critical')
146
+ return blocking.length || checkRollup === 'failure' ? 'needs-changes' : 'success'
147
+ }
@@ -0,0 +1,107 @@
1
+ // GitHub Actions: workflow runs, their jobs and steps, rerun/cancel, and the logs
2
+ // archive URL (a redirect to a zip — the tool returns the URL instead of downloading
3
+ // a binary into the conversation).
4
+
5
+ function ensureRepo({ owner, repo }) {
6
+ if (!owner || !repo) {
7
+ throw new Error('owner and repo are required (pass "repository": "owner/repo" or set defaults in settings)')
8
+ }
9
+ }
10
+
11
+ export function normalizeRun(data) {
12
+ if (!data || typeof data !== 'object') return data
13
+ return {
14
+ id: data.id,
15
+ name: data.name,
16
+ displayTitle: data.display_title,
17
+ workflow: data.workflow_id,
18
+ event: data.event,
19
+ status: data.status,
20
+ conclusion: data.conclusion,
21
+ branch: data.head_branch,
22
+ sha: data.head_sha,
23
+ attempt: data.run_attempt,
24
+ createdAt: data.created_at,
25
+ updatedAt: data.updated_at,
26
+ url: data.html_url,
27
+ actor: data.actor && data.actor.login,
28
+ }
29
+ }
30
+
31
+ export async function listRuns(client, { owner, repo, branch, workflow, status, limit = 20 }) {
32
+ ensureRepo({ owner, repo })
33
+ const capped = Math.max(1, Math.min(Number(limit) || 20, 100))
34
+ const route = workflow
35
+ ? `/repos/${owner}/${repo}/actions/workflows/${encodeURIComponent(workflow)}/runs`
36
+ : `/repos/${owner}/${repo}/actions/runs`
37
+ const query = { per_page: capped }
38
+ if (branch) query.branch = branch
39
+ if (status) query.status = status
40
+ const { data } = await client.get(route, { query })
41
+ const runs = ((data && data.workflow_runs) || []).map(normalizeRun)
42
+ return { runs, count: runs.length, total: (data && data.total_count) || runs.length }
43
+ }
44
+
45
+ export async function getRun(client, { owner, repo, runId }) {
46
+ ensureRepo({ owner, repo })
47
+ if (!runId) throw new Error('runId is required')
48
+ const { data } = await client.get(`/repos/${owner}/${repo}/actions/runs/${runId}`)
49
+ return normalizeRun(data)
50
+ }
51
+
52
+ /** Jobs with their steps: what actually failed, without parsing a log archive. */
53
+ export async function listRunJobs(client, { owner, repo, runId }) {
54
+ ensureRepo({ owner, repo })
55
+ if (!runId) throw new Error('runId is required')
56
+ const { data } = await client.get(`/repos/${owner}/${repo}/actions/runs/${runId}/jobs`, {
57
+ query: { per_page: 100 },
58
+ })
59
+ const jobs = ((data && data.jobs) || []).map((job) => ({
60
+ id: job.id,
61
+ name: job.name,
62
+ status: job.status,
63
+ conclusion: job.conclusion,
64
+ startedAt: job.started_at,
65
+ completedAt: job.completed_at,
66
+ url: job.html_url,
67
+ failedSteps: (job.steps || [])
68
+ .filter((s) => s.conclusion && !['success', 'skipped', 'neutral'].includes(s.conclusion))
69
+ .map((s) => ({ number: s.number, name: s.name, conclusion: s.conclusion })),
70
+ }))
71
+ return { jobs, count: jobs.length }
72
+ }
73
+
74
+ export async function rerunRun(client, { owner, repo, runId, failedOnly = false }) {
75
+ ensureRepo({ owner, repo })
76
+ if (!runId) throw new Error('runId is required')
77
+ const path = failedOnly
78
+ ? `/repos/${owner}/${repo}/actions/runs/${runId}/rerun-failed-jobs`
79
+ : `/repos/${owner}/${repo}/actions/runs/${runId}/rerun`
80
+ await client.post(path, {})
81
+ return { rerun: true, runId, failedOnly: Boolean(failedOnly) }
82
+ }
83
+
84
+ export async function cancelRun(client, { owner, repo, runId }) {
85
+ ensureRepo({ owner, repo })
86
+ if (!runId) throw new Error('runId is required')
87
+ await client.post(`/repos/${owner}/${repo}/actions/runs/${runId}/cancel`, {})
88
+ return { cancelled: true, runId }
89
+ }
90
+
91
+ /**
92
+ * Where the run logs live. GitHub answers the logs endpoint with a redirect to a zip,
93
+ * so the tool returns the URL (short-lived, needs the token to download) instead of
94
+ * pulling a binary into the conversation.
95
+ */
96
+ export async function getRunLogsUrl(client, { owner, repo, runId }) {
97
+ ensureRepo({ owner, repo })
98
+ if (!runId) throw new Error('runId is required')
99
+ const { location, status } = await client.getRedirect(`/repos/${owner}/${repo}/actions/runs/${runId}/logs`)
100
+ return {
101
+ runId,
102
+ downloadUrl: location || '',
103
+ note: location
104
+ ? 'The archive is a zip and needs the same token to download; open it outside the conversation.'
105
+ : `GitHub answered HTTP ${status} without a download location — the logs may have expired.`,
106
+ }
107
+ }
@@ -0,0 +1,44 @@
1
+ // Generic `gh api` pass-through with an explicit safety boundary.
2
+ //
3
+ // Reads are free. Anything that changes state needs `confirm: true`, and a small
4
+ // deny list refuses the calls that would be destructive in a way a single
5
+ // confirmation cannot undo (deleting repositories, transferring ownership).
6
+
7
+ const MUTATING = new Set(['POST', 'PATCH', 'PUT', 'DELETE'])
8
+
9
+ const DENY = [
10
+ { method: 'DELETE', pattern: /^\/repos\/[^/]+\/[^/]+$/, reason: 'deleting a repository is refused' },
11
+ { method: 'POST', pattern: /^\/repos\/[^/]+\/[^/]+\/transfer$/, reason: 'transferring a repository is refused' },
12
+ { method: 'DELETE', pattern: /^\/orgs\/[^/]+$/, reason: 'deleting an organization is refused' },
13
+ ]
14
+
15
+ export function validateApiCall({ method, path, confirm }) {
16
+ const m = String(method || 'GET').toUpperCase()
17
+ if (!path || !String(path).startsWith('/')) {
18
+ throw new Error('path must start with "/" and be relative to the API base, e.g. "/repos/owner/repo"')
19
+ }
20
+ for (const rule of DENY) {
21
+ if (rule.method === m && rule.pattern.test(path)) {
22
+ throw new Error(`${rule.reason} (${m} ${path})`)
23
+ }
24
+ }
25
+ const mutating = MUTATING.has(m)
26
+ if (mutating && confirm !== true) {
27
+ throw new Error(`refusing ${m} ${path}: pass confirm: true to run a state-changing call`)
28
+ }
29
+ return { method: m, mutating }
30
+ }
31
+
32
+ /** Run one generic API call. `body` may be an object (JSON) — it is passed as-is. */
33
+ export async function callApi(client, { method = 'GET', path, query, body, confirm = false, graphql } = {}) {
34
+ if (graphql) {
35
+ const data = await client.graphql(graphql, body && typeof body === 'object' ? body : undefined)
36
+ return { graphql: true, data }
37
+ }
38
+ const { method: m, mutating } = validateApiCall({ method, path, confirm })
39
+ const opts = { query }
40
+ const { data, status, rateLimit } = m === 'GET' || m === 'DELETE'
41
+ ? await client.request(m, path, opts)
42
+ : await client.request(m, path, { ...opts, body })
43
+ return { method: m, mutating, status, rateLimit, data }
44
+ }
@@ -0,0 +1,101 @@
1
+ // Issue operations: listing (pull requests come back marked `kind: "pr"`), reading
2
+ // with comments, creating, commenting, closing, and the cross-repository search
3
+ // endpoint (which has its own quota).
4
+
5
+ function ensureRepo({ owner, repo }) {
6
+ if (!owner || !repo) {
7
+ throw new Error('owner and repo are required (pass "repository": "owner/repo" or set defaults in settings)')
8
+ }
9
+ }
10
+
11
+ export function normalizeIssue(data, { mine = '' } = {}) {
12
+ if (!data || typeof data !== 'object') return data
13
+ const isPull = Boolean(data.pull_request)
14
+ return {
15
+ number: data.number,
16
+ kind: isPull ? 'pr' : 'issue',
17
+ title: data.title,
18
+ state: data.state,
19
+ stateReason: data.state_reason || null,
20
+ author: data.user && data.user.login,
21
+ labels: Array.isArray(data.labels)
22
+ ? data.labels.map((l) => (typeof l === 'string' ? l : l.name))
23
+ : [],
24
+ assignees: Array.isArray(data.assignees) ? data.assignees.map((a) => a.login) : [],
25
+ comments: data.comments,
26
+ createdAt: data.created_at,
27
+ updatedAt: data.updated_at,
28
+ closedAt: data.closed_at,
29
+ url: data.html_url,
30
+ body: typeof data.body === 'string' ? data.body : '',
31
+ isMine: mine ? (data.user && data.user.login) === mine : undefined,
32
+ }
33
+ }
34
+
35
+ export async function listIssues(client, { owner, repo, state = 'open', labels, limit = 30, kind = 'all' }) {
36
+ ensureRepo({ owner, repo })
37
+ const capped = Math.max(1, Math.min(Number(limit) || 30, 100))
38
+ const query = { state, per_page: capped }
39
+ if (labels) query.labels = Array.isArray(labels) ? labels.join(',') : labels
40
+ const { data } = await client.get(`/repos/${owner}/${repo}/issues`, { query })
41
+ let issues = Array.isArray(data) ? data.map((i) => normalizeIssue(i)) : []
42
+ if (kind === 'pr') issues = issues.filter((i) => i.kind === 'pr')
43
+ if (kind === 'issue') issues = issues.filter((i) => i.kind === 'issue')
44
+ return { issues, count: issues.length }
45
+ }
46
+
47
+ export async function getIssue(client, { owner, repo, number, includeComments = false }) {
48
+ ensureRepo({ owner, repo })
49
+ if (!number) throw new Error('number is required')
50
+ const { data } = await client.get(`/repos/${owner}/${repo}/issues/${number}`)
51
+ const issue = normalizeIssue(data)
52
+ if (!includeComments) return issue
53
+ const { data: comments } = await client.get(`/repos/${owner}/${repo}/issues/${number}/comments`, {
54
+ query: { per_page: 100 },
55
+ })
56
+ issue.commentList = Array.isArray(comments)
57
+ ? comments.map((c) => ({ id: c.id, author: c.user && c.user.login, createdAt: c.created_at, body: c.body }))
58
+ : []
59
+ return issue
60
+ }
61
+
62
+ export async function createIssue(client, { owner, repo, title, body, labels, assignees }) {
63
+ ensureRepo({ owner, repo })
64
+ if (!title) throw new Error('title is required')
65
+ const payload = { title, body: body || '' }
66
+ if (labels) payload.labels = Array.isArray(labels) ? labels : [labels]
67
+ if (assignees) payload.assignees = Array.isArray(assignees) ? assignees : [assignees]
68
+ const { data } = await client.post(`/repos/${owner}/${repo}/issues`, payload)
69
+ return normalizeIssue(data)
70
+ }
71
+
72
+ export async function commentIssue(client, { owner, repo, number, body }) {
73
+ ensureRepo({ owner, repo })
74
+ if (!number) throw new Error('number is required')
75
+ if (!body) throw new Error('body is required')
76
+ const { data } = await client.post(`/repos/${owner}/${repo}/issues/${number}/comments`, { body })
77
+ return { id: data && data.id, url: data && data.html_url, createdAt: data && data.created_at }
78
+ }
79
+
80
+ export async function closeIssue(client, { owner, repo, number, stateReason }) {
81
+ ensureRepo({ owner, repo })
82
+ if (!number) throw new Error('number is required')
83
+ const payload = { state: 'closed' }
84
+ if (stateReason) payload.state_reason = stateReason
85
+ const { data } = await client.patch(`/repos/${owner}/${repo}/issues/${number}`, payload)
86
+ return normalizeIssue(data)
87
+ }
88
+
89
+ export async function searchIssues(client, { query, limit = 20 }) {
90
+ if (!query) throw new Error('query is required')
91
+ const capped = Math.max(1, Math.min(Number(limit) || 20, 100))
92
+ const { data } = await client.get('/search/issues', { query: { q: query, per_page: capped } })
93
+ const items = (data && data.items) || []
94
+ return {
95
+ total: (data && data.total_count) || items.length,
96
+ issues: items.map((i) => ({
97
+ ...normalizeIssue(i),
98
+ repository: i.repository_url ? String(i.repository_url).replace(/^.*\/repos\//, '') : undefined,
99
+ })),
100
+ }
101
+ }
@@ -0,0 +1,163 @@
1
+ // Sanitized mirror publication.
2
+ //
3
+ // A GitHub mirror is a product channel, not a copy of the history: only the npm
4
+ // package contents plus the files a reader needs may reach it. Development sources,
5
+ // build scripts, caches and agent instructions stay in the source forge.
6
+ //
7
+ // The git binary is injected as a runner (`git(args, { cwd, env })`), so the whole
8
+ // flow is testable without a repository, and the caller decides which working copy
9
+ // and which remote are used. Publication is always a fast-forward on top of the
10
+ // mirror branch — rewriting mirror history is deliberately not implemented.
11
+
12
+ export const DEFAULT_ALLOW = [
13
+ '.gitignore', 'LICENSE', 'README.md', 'README.ru.md', 'README.zh.md',
14
+ 'CHANGELOG.md', 'package.json', 'cordis.patch.yml',
15
+ ]
16
+
17
+ // `docs/**` is internal documentation and never belongs to a publication channel, so it
18
+ // is forbidden even when a manifest happens to list it. `scripts/**` is NOT forbidden:
19
+ // some packages legitimately ship build tooling, and the allowlist (the manifest's own
20
+ // `files`) is what decides — the mirror publishes exactly the npm contents.
21
+ export const DEFAULT_FORBIDDEN = [
22
+ 'AGENTS.md', 'index.md', 'docs', 'deploy.sh', '.gitea', '.github', '.worktrees',
23
+ '.planning', '__pycache__', '.ruff_cache', '.venv', 'node_modules',
24
+ ]
25
+
26
+ export const DEFAULT_REQUIRED = ['package.json', 'README.md']
27
+
28
+ function matches(path, entries) {
29
+ return entries.some((entry) => {
30
+ const clean = String(entry).replace(/\/+$/, '')
31
+ if (!clean) return false
32
+ return path === clean || path.startsWith(`${clean}/`)
33
+ })
34
+ }
35
+
36
+ /** Split a tree into what may be published and what must stay behind. */
37
+ export function sanitizePaths(paths, { allow = DEFAULT_ALLOW, forbidden = DEFAULT_FORBIDDEN } = {}) {
38
+ const publish = []
39
+ const drop = []
40
+ for (const path of paths) {
41
+ if (matches(path, forbidden) || !matches(path, allow)) drop.push(path)
42
+ else publish.push(path)
43
+ }
44
+ publish.sort()
45
+ drop.sort()
46
+ return { publish, drop }
47
+ }
48
+
49
+ /** Allowlist from a package manifest's `files` field, plus the reader-facing files. */
50
+ export function allowlistFromManifest(manifest, extra = DEFAULT_ALLOW) {
51
+ const fromFiles = Array.isArray(manifest && manifest.files) ? manifest.files : []
52
+ const cleaned = fromFiles
53
+ .map((entry) => String(entry).replace(/\/\*\*$/, '').replace(/\/+$/, ''))
54
+ .filter(Boolean)
55
+ return [...new Set([...extra, ...cleaned])]
56
+ }
57
+
58
+ function parseLsTree(stdout) {
59
+ return String(stdout || '')
60
+ .split('\n')
61
+ .map((line) => line.trim())
62
+ .filter(Boolean)
63
+ }
64
+
65
+ async function readManifest(git, { cwd, ref }) {
66
+ try {
67
+ const res = await git(['show', `${ref}:package.json`], { cwd })
68
+ if (res.code !== 0) return null
69
+ return JSON.parse(res.stdout)
70
+ } catch {
71
+ return null
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Read-only plan: which files would be published, how many would be left behind, and
77
+ * whether the publication must be refused (forbidden path present, required file missing).
78
+ */
79
+ export async function planMirror({ git, cwd, ref = 'origin/main', allow, forbidden, required = DEFAULT_REQUIRED }) {
80
+ const manifest = await readManifest(git, { cwd, ref })
81
+ const allowlist = allow || allowlistFromManifest(manifest)
82
+ const ls = await git(['ls-tree', '-r', '--name-only', ref], { cwd })
83
+ if (ls.code !== 0) {
84
+ throw new Error(`cannot read the tree of ${ref}: ${(ls.stderr || '').trim() || 'git ls-tree failed'}`)
85
+ }
86
+ const paths = parseLsTree(ls.stdout)
87
+ const { publish, drop } = sanitizePaths(paths, { allow: allowlist, forbidden })
88
+ const forbiddenEntries = forbidden || DEFAULT_FORBIDDEN
89
+ // A forbidden path inside the allowlist is a configuration mistake, not a file to
90
+ // publish: surface it instead of silently dropping it.
91
+ const allowlistConflicts = allowlist.filter((entry) => matches(entry, forbiddenEntries))
92
+ const missing = required.filter((r) => !publish.includes(r))
93
+ const sha = (await git(['rev-parse', ref], { cwd })).stdout.trim()
94
+ return {
95
+ ref,
96
+ sha,
97
+ publish,
98
+ drop,
99
+ total: paths.length,
100
+ allowlistConflicts,
101
+ missingRequired: missing,
102
+ refused: allowlistConflicts.length
103
+ ? `the allowlist contains forbidden paths: ${allowlistConflicts.join(', ')}`
104
+ : missing.length
105
+ ? `required product files are missing: ${missing.join(', ')}`
106
+ : null,
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Publish the sanitized tree on top of the mirror branch (fast-forward, never a force).
112
+ * With dryRun the plan is returned and nothing is written or pushed.
113
+ */
114
+ export async function publishMirror({
115
+ git, cwd, ref = 'origin/main', mirror = 'github', branch = 'main',
116
+ allow, forbidden, required = DEFAULT_REQUIRED, dryRun = false, message,
117
+ }) {
118
+ const plan = await planMirror({ git, cwd, ref, allow, forbidden, required })
119
+ if (plan.refused) {
120
+ const err = new Error(plan.refused)
121
+ err.plan = plan
122
+ throw err
123
+ }
124
+ const source = plan.sha
125
+ const mirrorRef = `${mirror}/${branch}`
126
+ const head = await git(['rev-parse', mirrorRef], { cwd })
127
+ if (head.code !== 0) {
128
+ throw new Error(`mirror branch ${mirrorRef} is not available locally: run "git fetch ${mirror} ${branch}" first`)
129
+ }
130
+ const parent = head.stdout.trim()
131
+
132
+ if (dryRun) return { ...plan, mirror: mirrorRef, parent, commit: null, pushed: false }
133
+
134
+ const indexFile = `/tmp/dsh-github-ops-mirror-${process.pid}-${Date.now()}.index`
135
+ const env = { GIT_INDEX_FILE: indexFile }
136
+ try {
137
+ await git(['read-tree', '--empty'], { cwd, env })
138
+ for (const path of plan.publish) {
139
+ const blob = (await git(['rev-parse', `${ref}:${path}`], { cwd })).stdout.trim()
140
+ const updated = await git(['update-index', '--add', '--cacheinfo', `100644,${blob},${path}`], { cwd, env })
141
+ if (updated.code !== 0) throw new Error(`cannot stage ${path}: ${(updated.stderr || '').trim()}`)
142
+ }
143
+ const tree = (await git(['write-tree'], { cwd, env })).stdout.trim()
144
+ const commitMessage = message || `chore(publish): sanitized tree from ${source.slice(0, 8)}`
145
+ const commitRes = await git(['commit-tree', tree, '-p', parent, '-m', commitMessage], { cwd })
146
+ if (commitRes.code !== 0) throw new Error(`cannot create the mirror commit: ${(commitRes.stderr || '').trim()}`)
147
+ const commit = commitRes.stdout.trim()
148
+ const push = await git(['push', mirror, `${commit}:refs/heads/${branch}`], { cwd })
149
+ if (push.code !== 0) {
150
+ throw new Error(`push to ${mirrorRef} failed: ${(push.stderr || push.stdout || '').trim() || 'unknown error'}`)
151
+ }
152
+ return {
153
+ ...plan,
154
+ mirror: mirrorRef,
155
+ parent,
156
+ commit,
157
+ pushed: true,
158
+ correspondence: { source, mirror: commit },
159
+ }
160
+ } finally {
161
+ await git(['update-index', '--refresh'], { cwd, env }).catch(() => {})
162
+ }
163
+ }