@delfini/action-core 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.
- package/README.md +37 -0
- package/dist/adapters/factory.d.ts +3 -0
- package/dist/adapters/factory.d.ts.map +1 -0
- package/dist/adapters/factory.js +2 -0
- package/dist/adapters/single-call/model.d.ts +4 -0
- package/dist/adapters/single-call/model.d.ts.map +1 -0
- package/dist/adapters/single-call/model.js +46 -0
- package/dist/adapters/single-call/orchestrator.d.ts +10 -0
- package/dist/adapters/single-call/orchestrator.d.ts.map +1 -0
- package/dist/adapters/single-call/orchestrator.js +97 -0
- package/dist/adapters/single-call/prompt.md +360 -0
- package/dist/analysis-input.d.ts +18 -0
- package/dist/analysis-input.d.ts.map +1 -0
- package/dist/analysis-input.js +60 -0
- package/dist/diff-builder.d.ts +16 -0
- package/dist/diff-builder.d.ts.map +1 -0
- package/dist/diff-builder.js +35 -0
- package/dist/doc-exclusion.d.ts +17 -0
- package/dist/doc-exclusion.d.ts.map +1 -0
- package/dist/doc-exclusion.js +85 -0
- package/dist/doc-reader.d.ts +34 -0
- package/dist/doc-reader.d.ts.map +1 -0
- package/dist/doc-reader.js +172 -0
- package/dist/github-client-shared.d.ts +27 -0
- package/dist/github-client-shared.d.ts.map +1 -0
- package/dist/github-client-shared.js +240 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/pipeline-inputs.d.ts +23 -0
- package/dist/pipeline-inputs.d.ts.map +1 -0
- package/dist/pipeline-inputs.js +42 -0
- package/dist/ports/orchestrator.d.ts +5 -0
- package/dist/ports/orchestrator.d.ts.map +1 -0
- package/dist/ports/orchestrator.js +1 -0
- package/dist/smart-skip.d.ts +10 -0
- package/dist/smart-skip.d.ts.map +1 -0
- package/dist/smart-skip.js +85 -0
- package/package.json +41 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import * as core from '@actions/core';
|
|
2
|
+
import * as github from '@actions/github';
|
|
3
|
+
import { buildUnifiedDiff } from './diff-builder.js';
|
|
4
|
+
import { filterDiff } from '@delfini/drift-engine';
|
|
5
|
+
// Story P2.2 (AC6) — extracted verbatim from pipeline.ts. The Lite pipeline
|
|
6
|
+
// reuses this without importing pipeline.ts as a value, which would pull the
|
|
7
|
+
// FR88g/FR88d module graph (config-client / stream-routing / intake-client)
|
|
8
|
+
// into lite-pipeline.ts's runtime graph and break the epic's "Lite never calls
|
|
9
|
+
// FR88g/FR88d" invariant. `buildUnifiedDiff` + the `@actions/github` context
|
|
10
|
+
// carry no FR88d coupling, so this is a clean shared module.
|
|
11
|
+
export function buildAnalysisInput(ctx, changedFiles, docs, options = {}) {
|
|
12
|
+
let diff = buildUnifiedDiff(changedFiles);
|
|
13
|
+
if (options.enableDiffPreFilter === true) {
|
|
14
|
+
const result = filterDiff(diff);
|
|
15
|
+
diff = result.keptDiff;
|
|
16
|
+
// One info line; no per-path logs to keep a large lockfile churn from
|
|
17
|
+
// flooding the Action log. Counts only — sufficient to diagnose under-
|
|
18
|
+
// or over-aggressive filtering without leaking diff content.
|
|
19
|
+
//
|
|
20
|
+
// Count BOTH droppedPaths and droppedHunks per reason: a file whose every
|
|
21
|
+
// hunk is whitespace-only / import-only is promoted to a path-level drop
|
|
22
|
+
// with that reason, so a path-bucket-only summary would render whole-file
|
|
23
|
+
// whitespace/import drops invisible (AC3 — drops must never be silently
|
|
24
|
+
// discarded).
|
|
25
|
+
const counts = countByReason([
|
|
26
|
+
...result.droppedPaths.map((p) => p.reason),
|
|
27
|
+
...result.droppedHunks.map((h) => h.reason),
|
|
28
|
+
]);
|
|
29
|
+
core.info(`Delfini diff pre-filter dropped: lockfiles=${counts.lockfile} ` +
|
|
30
|
+
`generated=${counts.generated} vendored=${counts.vendored} ` +
|
|
31
|
+
`fixtures=${counts.fixture} whitespace=${counts['whitespace-only']} ` +
|
|
32
|
+
`import=${counts['import-only']}`);
|
|
33
|
+
}
|
|
34
|
+
const prTitle = github.context.payload.pull_request?.title ?? '';
|
|
35
|
+
return {
|
|
36
|
+
diff,
|
|
37
|
+
docs,
|
|
38
|
+
prMetadata: {
|
|
39
|
+
owner: ctx.owner,
|
|
40
|
+
repo: ctx.repo,
|
|
41
|
+
prNumber: ctx.pullNumber,
|
|
42
|
+
headSha: ctx.headSha,
|
|
43
|
+
baseSha: ctx.baseSha,
|
|
44
|
+
title: prTitle,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function countByReason(reasons) {
|
|
49
|
+
const out = {
|
|
50
|
+
lockfile: 0,
|
|
51
|
+
generated: 0,
|
|
52
|
+
vendored: 0,
|
|
53
|
+
fixture: 0,
|
|
54
|
+
'whitespace-only': 0,
|
|
55
|
+
'import-only': 0,
|
|
56
|
+
};
|
|
57
|
+
for (const r of reasons)
|
|
58
|
+
out[r]++;
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ChangedFile } from './github-client-shared.js';
|
|
2
|
+
/**
|
|
3
|
+
* Builds a unified-diff string from the GitHub API's per-file patch entries.
|
|
4
|
+
*
|
|
5
|
+
* GitHub's `pulls.listFiles` returns each file's hunks (the `@@ ... @@` content)
|
|
6
|
+
* but omits the `diff --git` / `--- a/… +++ b/…` preamble. We synthesise that
|
|
7
|
+
* preamble so the resulting string is a valid multi-file unified diff and so
|
|
8
|
+
* the single-call prompt-builder's `diff --git` counter (for `changedFileCount`)
|
|
9
|
+
* sees the right file count.
|
|
10
|
+
*
|
|
11
|
+
* Files without a `patch` (binary, renamed-only, or >GitHub's per-file size cap)
|
|
12
|
+
* are skipped entirely per the Story 3.2 design decision: we never lie to the
|
|
13
|
+
* LLM about what changed when we can't actually show it.
|
|
14
|
+
*/
|
|
15
|
+
export declare function buildUnifiedDiff(files: ChangedFile[]): string;
|
|
16
|
+
//# sourceMappingURL=diff-builder.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"diff-builder.d.ts","sourceRoot":"","sources":["../src/diff-builder.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAA;AAE5D;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,WAAW,EAAE,GAAG,MAAM,CAuB7D"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a unified-diff string from the GitHub API's per-file patch entries.
|
|
3
|
+
*
|
|
4
|
+
* GitHub's `pulls.listFiles` returns each file's hunks (the `@@ ... @@` content)
|
|
5
|
+
* but omits the `diff --git` / `--- a/… +++ b/…` preamble. We synthesise that
|
|
6
|
+
* preamble so the resulting string is a valid multi-file unified diff and so
|
|
7
|
+
* the single-call prompt-builder's `diff --git` counter (for `changedFileCount`)
|
|
8
|
+
* sees the right file count.
|
|
9
|
+
*
|
|
10
|
+
* Files without a `patch` (binary, renamed-only, or >GitHub's per-file size cap)
|
|
11
|
+
* are skipped entirely per the Story 3.2 design decision: we never lie to the
|
|
12
|
+
* LLM about what changed when we can't actually show it.
|
|
13
|
+
*/
|
|
14
|
+
export function buildUnifiedDiff(files) {
|
|
15
|
+
const parts = [];
|
|
16
|
+
for (const file of files) {
|
|
17
|
+
if (!file.patch)
|
|
18
|
+
continue;
|
|
19
|
+
parts.push(`diff --git a/${file.filename} b/${file.filename}`);
|
|
20
|
+
if (file.status === 'added') {
|
|
21
|
+
parts.push(`--- /dev/null`);
|
|
22
|
+
parts.push(`+++ b/${file.filename}`);
|
|
23
|
+
}
|
|
24
|
+
else if (file.status === 'removed') {
|
|
25
|
+
parts.push(`--- a/${file.filename}`);
|
|
26
|
+
parts.push(`+++ /dev/null`);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
parts.push(`--- a/${file.filename}`);
|
|
30
|
+
parts.push(`+++ b/${file.filename}`);
|
|
31
|
+
}
|
|
32
|
+
parts.push(file.patch);
|
|
33
|
+
}
|
|
34
|
+
return parts.join('\n');
|
|
35
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type ExclusionReason = 'front-matter';
|
|
2
|
+
export interface ExcludedDoc {
|
|
3
|
+
path: string;
|
|
4
|
+
reason: ExclusionReason;
|
|
5
|
+
detail?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface FrontMatterResult {
|
|
8
|
+
ignore: boolean;
|
|
9
|
+
reason?: string;
|
|
10
|
+
body: string;
|
|
11
|
+
frontMatterLineCount: number;
|
|
12
|
+
}
|
|
13
|
+
type WarnFn = (message: string) => void;
|
|
14
|
+
export declare function parseFrontMatter(markdown: string, onWarn?: WarnFn): FrontMatterResult;
|
|
15
|
+
export declare function stripFrontMatter(markdown: string): string;
|
|
16
|
+
export {};
|
|
17
|
+
//# sourceMappingURL=doc-exclusion.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"doc-exclusion.d.ts","sourceRoot":"","sources":["../src/doc-exclusion.ts"],"names":[],"mappings":"AAYA,MAAM,MAAM,eAAe,GAAG,cAAc,CAAA;AAE5C,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,eAAe,CAAA;IACvB,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,OAAO,CAAA;IACf,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;IAOZ,oBAAoB,EAAE,MAAM,CAAA;CAC7B;AAuBD,KAAK,MAAM,GAAG,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;AAEvC,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,MAAM,EAChB,MAAM,GAAE,MAAiB,GACxB,iBAAiB,CAuEnB;AAED,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAMzD"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import matter from 'gray-matter';
|
|
2
|
+
// Counts the number of lines occupied by the YAML front-matter block in `markdown`.
|
|
3
|
+
// Returns 0 when no front-matter is present. Robust to CRLF / LF; the closing
|
|
4
|
+
// `---` may sit on its own line or be followed by trailing whitespace — both
|
|
5
|
+
// shapes are matched. If a leading `---` is present but no closing `---` is
|
|
6
|
+
// found within a reasonable bound, returns 0 (defensive — prevents counting
|
|
7
|
+
// the entire file as front-matter on a malformed doc).
|
|
8
|
+
const FRONT_MATTER_OPEN_RE = /^---\r?\n/;
|
|
9
|
+
function countFrontMatterLines(markdown) {
|
|
10
|
+
if (!FRONT_MATTER_OPEN_RE.test(markdown))
|
|
11
|
+
return 0;
|
|
12
|
+
const lines = markdown.split(/\r?\n/);
|
|
13
|
+
// First line is the opening `---`. Search for the closing `---` from line 2.
|
|
14
|
+
for (let i = 1; i < lines.length; i++) {
|
|
15
|
+
if (lines[i] === '---') {
|
|
16
|
+
// i is 0-indexed; the closing `---` is on line `i + 1` (1-indexed),
|
|
17
|
+
// and the block occupies that many lines.
|
|
18
|
+
return i + 1;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
export function parseFrontMatter(markdown, onWarn = () => { }) {
|
|
24
|
+
// Compute the line count from the original markdown — independent of
|
|
25
|
+
// gray-matter's parse outcome — so a malformed YAML fallback still produces
|
|
26
|
+
// a useful `frontMatterLineCount` rather than `0`. (Counter-argument: a
|
|
27
|
+
// malformed YAML block is also a sign the structure is suspect; defaulting
|
|
28
|
+
// the count to 0 in the catch path matches the body fallback to original
|
|
29
|
+
// markdown — both say "treat the whole file as body".)
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = matter(markdown);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
36
|
+
onWarn(`Failed to parse front-matter: ${reason}`);
|
|
37
|
+
return { ignore: false, body: markdown, frontMatterLineCount: 0 };
|
|
38
|
+
}
|
|
39
|
+
const data = parsed.data;
|
|
40
|
+
const body = parsed.content;
|
|
41
|
+
const frontMatterLineCount = countFrontMatterLines(markdown);
|
|
42
|
+
if (!('delfini' in data)) {
|
|
43
|
+
return { ignore: false, body, frontMatterLineCount };
|
|
44
|
+
}
|
|
45
|
+
const value = data.delfini;
|
|
46
|
+
// Shorthand: delfini: ignore | skip (or delfini: true)
|
|
47
|
+
if (typeof value === 'string') {
|
|
48
|
+
const normalized = value.trim().toLowerCase();
|
|
49
|
+
if (normalized === 'ignore' || normalized === 'skip') {
|
|
50
|
+
return { ignore: true, body, frontMatterLineCount };
|
|
51
|
+
}
|
|
52
|
+
onWarn(`Unrecognised front-matter value for 'delfini': "${value}" — expected "ignore" or "skip". Treating as not-ignored.`);
|
|
53
|
+
return { ignore: false, body, frontMatterLineCount };
|
|
54
|
+
}
|
|
55
|
+
if (typeof value === 'boolean') {
|
|
56
|
+
return { ignore: value, body, frontMatterLineCount };
|
|
57
|
+
}
|
|
58
|
+
if (value !== null && typeof value === 'object') {
|
|
59
|
+
const obj = value;
|
|
60
|
+
const ignoreFlag = obj.ignore;
|
|
61
|
+
const skipFlag = obj.skip;
|
|
62
|
+
const rawReason = obj.reason;
|
|
63
|
+
const reason = typeof rawReason === 'string' && rawReason.trim().length > 0
|
|
64
|
+
? rawReason.trim()
|
|
65
|
+
: undefined;
|
|
66
|
+
if (ignoreFlag === true || skipFlag === true) {
|
|
67
|
+
return { ignore: true, reason, body, frontMatterLineCount };
|
|
68
|
+
}
|
|
69
|
+
if (ignoreFlag === false || skipFlag === false) {
|
|
70
|
+
return { ignore: false, body, frontMatterLineCount };
|
|
71
|
+
}
|
|
72
|
+
onWarn(`Front-matter 'delfini' object did not contain a boolean 'ignore' or 'skip' flag. Treating as not-ignored.`);
|
|
73
|
+
return { ignore: false, body, frontMatterLineCount };
|
|
74
|
+
}
|
|
75
|
+
onWarn(`Front-matter 'delfini' value has unsupported type (${typeof value}). Treating as not-ignored.`);
|
|
76
|
+
return { ignore: false, body, frontMatterLineCount };
|
|
77
|
+
}
|
|
78
|
+
export function stripFrontMatter(markdown) {
|
|
79
|
+
try {
|
|
80
|
+
return matter(markdown).content;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return markdown;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { GitHub } from '@actions/github/lib/utils';
|
|
2
|
+
import { type ExcludedDoc } from './doc-exclusion.js';
|
|
3
|
+
export interface DocFile {
|
|
4
|
+
path: string;
|
|
5
|
+
content: string;
|
|
6
|
+
frontMatterLineCount: number;
|
|
7
|
+
}
|
|
8
|
+
export interface DocsReadResult {
|
|
9
|
+
included: DocFile[];
|
|
10
|
+
excluded: ExcludedDoc[];
|
|
11
|
+
scope: ScopeSource;
|
|
12
|
+
}
|
|
13
|
+
export type ScopeSource = {
|
|
14
|
+
kind: 'docs_path';
|
|
15
|
+
docScope: string;
|
|
16
|
+
};
|
|
17
|
+
type Octokit = InstanceType<typeof GitHub>;
|
|
18
|
+
export type ReadDocsOptions = Record<string, never>;
|
|
19
|
+
export declare function readDocsViaGitTrees(octokit: Octokit, owner: string, repo: string, docScope: string[], ref: string, _options?: ReadDocsOptions): Promise<DocsReadResult>;
|
|
20
|
+
/**
|
|
21
|
+
* Lite-mode reader wrapper. Always reads at the PR head SHA (`ctx.headSha`),
|
|
22
|
+
* matching `github-client.ts:readDocs`'s rationale: walking at base would
|
|
23
|
+
* re-detect the same drift forever once Approve-and-Commit splices doc
|
|
24
|
+
* updates onto the PR branch (Story P2.6 carries the rationale forward;
|
|
25
|
+
* Lite mode has no Approve-and-Commit but the head-vs-base distinction is
|
|
26
|
+
* still the correct one for analysing "what the PR's current docs look like").
|
|
27
|
+
*
|
|
28
|
+
* Lives here rather than in `github-client.ts` to keep the new reader and its
|
|
29
|
+
* wrapper colocated. Story 3.12 retired `readDocsFromPath`; BOTH modes read
|
|
30
|
+
* via this git-trees path now (Full through `github-client.ts:readDocs`).
|
|
31
|
+
*/
|
|
32
|
+
export declare function readDocsAtHeadViaGitTrees(octokit: Octokit, owner: string, repo: string, docScope: string[], headSha: string, options?: ReadDocsOptions): Promise<DocsReadResult>;
|
|
33
|
+
export {};
|
|
34
|
+
//# sourceMappingURL=doc-reader.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"doc-reader.d.ts","sourceRoot":"","sources":["../src/doc-reader.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,2BAA2B,CAAA;AAEvD,OAAO,EAEL,KAAK,WAAW,EACjB,MAAM,oBAAoB,CAAA;AAE3B,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,CAAA;IAIf,oBAAoB,EAAE,MAAM,CAAA;CAC7B;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,OAAO,EAAE,CAAA;IACnB,QAAQ,EAAE,WAAW,EAAE,CAAA;IACvB,KAAK,EAAE,WAAW,CAAA;CACnB;AAQD,MAAM,MAAM,WAAW,GAAG;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAA;AAEjE,KAAK,OAAO,GAAG,YAAY,CAAC,OAAO,MAAM,CAAC,CAAA;AAE1C,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAA;AAmHnD,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,EAAE,EAClB,GAAG,EAAE,MAAM,EACX,QAAQ,GAAE,eAAoB,GAC7B,OAAO,CAAC,cAAc,CAAC,CA6EzB;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,EAAE,EAClB,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,cAAc,CAAC,CAEzB"}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import * as core from '@actions/core';
|
|
2
|
+
import { isFileInDocScope } from '@delfini/drift-engine';
|
|
3
|
+
import { parseFrontMatter, } from './doc-exclusion.js';
|
|
4
|
+
// Story 3.12 (ADR-2026-06-01) — the per-directory `getContent` walk
|
|
5
|
+
// (`readDocsFromPath` + `collectDocs`) is retired. BOTH modes now read docs
|
|
6
|
+
// via `readDocsViaGitTrees` (ONE recursive git-trees call + the shared
|
|
7
|
+
// `isFileInDocScope` matcher). Full mode reaches it through
|
|
8
|
+
// `readDocsAtHeadViaGitTrees` (github-client.ts:readDocs); Lite mode reaches
|
|
9
|
+
// it directly (lite-pipeline.ts).
|
|
10
|
+
async function processFile(octokit, owner, repo, filePath, ref, out) {
|
|
11
|
+
const content = await fetchFileContent(octokit, owner, repo, filePath, ref);
|
|
12
|
+
if (content === null) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const frontMatter = parseFrontMatter(content, (message) => {
|
|
16
|
+
core.warning(`${filePath}: ${message}`);
|
|
17
|
+
});
|
|
18
|
+
if (frontMatter.ignore) {
|
|
19
|
+
out.excluded.push({
|
|
20
|
+
path: filePath,
|
|
21
|
+
reason: 'front-matter',
|
|
22
|
+
detail: frontMatter.reason,
|
|
23
|
+
});
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
out.included.push({
|
|
27
|
+
path: filePath,
|
|
28
|
+
content: frontMatter.body,
|
|
29
|
+
frontMatterLineCount: frontMatter.frontMatterLineCount,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
async function fetchFileContent(octokit, owner, repo, path, ref) {
|
|
33
|
+
try {
|
|
34
|
+
const { data } = await octokit.rest.repos.getContent({
|
|
35
|
+
owner,
|
|
36
|
+
repo,
|
|
37
|
+
path,
|
|
38
|
+
ref,
|
|
39
|
+
});
|
|
40
|
+
if (Array.isArray(data) || data.type !== 'file' || !data.content) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return Buffer.from(data.content, 'base64').toString('utf-8');
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (isNotFoundError(error)) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function isNotFoundError(error) {
|
|
53
|
+
return (typeof error === 'object' &&
|
|
54
|
+
error !== null &&
|
|
55
|
+
'status' in error &&
|
|
56
|
+
error.status === 404);
|
|
57
|
+
}
|
|
58
|
+
// ----------------------------------------------------------------------------
|
|
59
|
+
// Story P2.6 / ADR-2026-06-01 — multi-path / glob doc-scope reader
|
|
60
|
+
// ----------------------------------------------------------------------------
|
|
61
|
+
//
|
|
62
|
+
// `readDocsViaGitTrees` is the new Lite-mode reader. It replaces the Lite
|
|
63
|
+
// pipeline's per-directory `getContent` walk (`readDocsFromPath`) with ONE
|
|
64
|
+
// recursive git-trees call, filters every blob path through the shared
|
|
65
|
+
// `isFileInDocScope` predicate from `@delfini/drift-engine` (picomatch@4
|
|
66
|
+
// dialect), then fetches only the matched `.md` blobs.
|
|
67
|
+
//
|
|
68
|
+
// Why git-trees: the Contents API has no glob support; the trees API is the
|
|
69
|
+
// only tractable way to glob a remote tree and is usually FEWER round-trips
|
|
70
|
+
// than the directory walk. Dialect parity with smart-skip is enforced by
|
|
71
|
+
// construction — the SAME `isFileInDocScope` runs against the SAME `string[]`
|
|
72
|
+
// scope on both sides (the 23-row dialect-parity fixture in
|
|
73
|
+
// `packages/drift-engine/__tests__/fixtures/doc-scope-dialect.json` gates it).
|
|
74
|
+
//
|
|
75
|
+
// Story 3.12 retired `readDocsFromPath` — Full mode now reaches this reader
|
|
76
|
+
// via `readDocsAtHeadViaGitTrees` (github-client.ts:readDocs), at full
|
|
77
|
+
// multi-path/glob parity with Lite mode.
|
|
78
|
+
const MAX_CONCURRENT_BLOB_FETCHES = 8;
|
|
79
|
+
const MARKDOWN_EXTENSIONS = ['.md', '.markdown'];
|
|
80
|
+
function isMarkdownPath(path) {
|
|
81
|
+
const lower = path.toLowerCase();
|
|
82
|
+
return MARKDOWN_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
|
83
|
+
}
|
|
84
|
+
export async function readDocsViaGitTrees(octokit, owner, repo, docScope, ref, _options = {}) {
|
|
85
|
+
// Joined string for the internal `ScopeSource.docScope` display field — keeps
|
|
86
|
+
// the existing `{kind: 'docs_path'; docScope: string}` shape byte-identical
|
|
87
|
+
// for downstream log/display consumers (Story P2.6 AC4 default per the
|
|
88
|
+
// Questions for the User answer). `ScopeSource` is internal to apps/action;
|
|
89
|
+
// widening to `string | string[]` is a forward option for Story 3.12.
|
|
90
|
+
const scopeDisplay = docScope.join(', ');
|
|
91
|
+
const result = {
|
|
92
|
+
included: [],
|
|
93
|
+
excluded: [],
|
|
94
|
+
scope: { kind: 'docs_path', docScope: scopeDisplay },
|
|
95
|
+
};
|
|
96
|
+
// Empty scope -> empty result, never analyse. Matches FR57(b)'s "no
|
|
97
|
+
// doc-in-scope files" baseline; never fall through to a recursive call
|
|
98
|
+
// against an empty scope (would match every blob).
|
|
99
|
+
if (docScope.length === 0) {
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
let treeData;
|
|
103
|
+
try {
|
|
104
|
+
const response = await octokit.rest.git.getTree({
|
|
105
|
+
owner,
|
|
106
|
+
repo,
|
|
107
|
+
tree_sha: ref,
|
|
108
|
+
// The Octokit REST typing accepts the string '1' here (cast on the
|
|
109
|
+
// wire to a query param). Boolean `true` works at runtime but is not
|
|
110
|
+
// strictly typed.
|
|
111
|
+
recursive: '1',
|
|
112
|
+
});
|
|
113
|
+
treeData = response.data;
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (isNotFoundError(error)) {
|
|
117
|
+
core.warning(`git tree for ref ${ref} not found — returning empty docs list`);
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
// V1 truncated-tree contract: warn-and-proceed. A truncated tree means the
|
|
123
|
+
// user has a very large monorepo and the response hit GitHub's size cap;
|
|
124
|
+
// some docs may be missed. A correctness-first follow-up walks the tree
|
|
125
|
+
// subtree-by-subtree on truncation — backlogged.
|
|
126
|
+
if (treeData.truncated) {
|
|
127
|
+
core.warning(`git tree for ${ref} was truncated by GitHub; some docs may be missed. ` +
|
|
128
|
+
'Consider narrowing docs_path or splitting the repo.');
|
|
129
|
+
}
|
|
130
|
+
const tree = (treeData.tree ?? []);
|
|
131
|
+
const matchedPaths = [];
|
|
132
|
+
for (const entry of tree) {
|
|
133
|
+
if (entry.type !== 'blob')
|
|
134
|
+
continue;
|
|
135
|
+
const path = entry.path;
|
|
136
|
+
if (typeof path !== 'string' || path.length === 0)
|
|
137
|
+
continue;
|
|
138
|
+
if (!isFileInDocScope(path, docScope))
|
|
139
|
+
continue;
|
|
140
|
+
// The predicate is path-shape-only (P3.6.1 AC5). The `.md` / `.markdown`
|
|
141
|
+
// restriction is an expander concern — apply it AFTER the in-scope check.
|
|
142
|
+
if (!isMarkdownPath(path))
|
|
143
|
+
continue;
|
|
144
|
+
matchedPaths.push(path);
|
|
145
|
+
}
|
|
146
|
+
// Concurrency-capped blob fetch. A repo with thousands of matched .md files
|
|
147
|
+
// could otherwise trip GitHub's secondary rate limit. The cap is a safety
|
|
148
|
+
// floor, not a perf knob.
|
|
149
|
+
for (let i = 0; i < matchedPaths.length; i += MAX_CONCURRENT_BLOB_FETCHES) {
|
|
150
|
+
const batch = matchedPaths.slice(i, i + MAX_CONCURRENT_BLOB_FETCHES);
|
|
151
|
+
await Promise.all(batch.map((path) => processFile(octokit, owner, repo, path, ref, result)));
|
|
152
|
+
}
|
|
153
|
+
// Stable output order — git-trees response order can vary; tests depend on
|
|
154
|
+
// determinism for snapshot-style assertions.
|
|
155
|
+
result.included.sort((a, b) => a.path.localeCompare(b.path));
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Lite-mode reader wrapper. Always reads at the PR head SHA (`ctx.headSha`),
|
|
160
|
+
* matching `github-client.ts:readDocs`'s rationale: walking at base would
|
|
161
|
+
* re-detect the same drift forever once Approve-and-Commit splices doc
|
|
162
|
+
* updates onto the PR branch (Story P2.6 carries the rationale forward;
|
|
163
|
+
* Lite mode has no Approve-and-Commit but the head-vs-base distinction is
|
|
164
|
+
* still the correct one for analysing "what the PR's current docs look like").
|
|
165
|
+
*
|
|
166
|
+
* Lives here rather than in `github-client.ts` to keep the new reader and its
|
|
167
|
+
* wrapper colocated. Story 3.12 retired `readDocsFromPath`; BOTH modes read
|
|
168
|
+
* via this git-trees path now (Full through `github-client.ts:readDocs`).
|
|
169
|
+
*/
|
|
170
|
+
export async function readDocsAtHeadViaGitTrees(octokit, owner, repo, docScope, headSha, options = {}) {
|
|
171
|
+
return readDocsViaGitTrees(octokit, owner, repo, docScope, headSha, options);
|
|
172
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { GitHub } from '@actions/github/lib/utils';
|
|
2
|
+
import type { DocFile, DocsReadResult, ReadDocsOptions } from './doc-reader.js';
|
|
3
|
+
export type { DocFile, DocsReadResult, ReadDocsOptions };
|
|
4
|
+
export interface PrContext {
|
|
5
|
+
owner: string;
|
|
6
|
+
repo: string;
|
|
7
|
+
pullNumber: number;
|
|
8
|
+
headSha: string;
|
|
9
|
+
baseSha: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ChangedFile {
|
|
12
|
+
filename: string;
|
|
13
|
+
status: string;
|
|
14
|
+
patch?: string;
|
|
15
|
+
}
|
|
16
|
+
type Octokit = InstanceType<typeof GitHub>;
|
|
17
|
+
export declare function getPrContext(): PrContext;
|
|
18
|
+
export declare function listChangedFiles(octokit: Octokit, ctx: PrContext): Promise<ChangedFile[]>;
|
|
19
|
+
export declare function getFileContent(octokit: Octokit, ctx: PrContext, path: string, ref: string): Promise<string | null>;
|
|
20
|
+
export declare function readDocs(octokit: Octokit, ctx: PrContext, docScope: string[], options?: ReadDocsOptions): Promise<DocsReadResult>;
|
|
21
|
+
export declare const DELFINI_PR_COMMENT_MARKER = "<!-- delfini-pr-comment -->";
|
|
22
|
+
export declare function postOrUpdatePrComment(octokit: Octokit, ctx: PrContext, body: string, options: {
|
|
23
|
+
marker: string;
|
|
24
|
+
}): Promise<void>;
|
|
25
|
+
export declare function isForbiddenError(error: unknown): boolean;
|
|
26
|
+
export declare function createCheckStatus(octokit: Octokit, ctx: PrContext, conclusion: 'in_progress' | 'success' | 'failure' | 'neutral' | 'action_required', title: string, summary: string, detailsUrl?: string): Promise<void>;
|
|
27
|
+
//# sourceMappingURL=github-client-shared.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"github-client-shared.d.ts","sourceRoot":"","sources":["../src/github-client-shared.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,2BAA2B,CAAA;AAEvD,OAAO,KAAK,EAAE,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAA;AAO/E,YAAY,EAAE,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,CAAA;AAExD,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,UAAU,EAAE,MAAM,CAAA;IAClB,OAAO,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,KAAK,OAAO,GAAG,YAAY,CAAC,OAAO,MAAM,CAAC,CAAA;AAE1C,wBAAgB,YAAY,IAAI,SAAS,CA4BxC;AAED,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,OAAO,EAChB,GAAG,EAAE,SAAS,GACb,OAAO,CAAC,WAAW,EAAE,CAAC,CAiCxB;AAED,wBAAsB,cAAc,CAClC,OAAO,EAAE,OAAO,EAChB,GAAG,EAAE,SAAS,EACd,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAyBxB;AAED,wBAAsB,QAAQ,CAC5B,OAAO,EAAE,OAAO,EAChB,GAAG,EAAE,SAAS,EACd,QAAQ,EAAE,MAAM,EAAE,EAClB,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,cAAc,CAAC,CAezB;AAsBD,eAAO,MAAM,yBAAyB,gCAAgC,CAAA;AAEtE,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,OAAO,EAChB,GAAG,EAAE,SAAS,EACd,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAC1B,OAAO,CAAC,IAAI,CAAC,CA8Ef;AAgBD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAExD;AAED,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,OAAO,EAChB,GAAG,EAAE,SAAS,EACd,UAAU,EAAE,aAAa,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,iBAAiB,EACjF,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EACf,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,IAAI,CAAC,CAqCf"}
|