@sanity/ailf 0.1.33 → 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/LICENSE +21 -0
- package/config/airbyte/ai_literacy_framework.connector.yaml +6 -0
- package/config/bigquery/views/reports.sql +1 -0
- package/dist/_vendor/ailf-core/examples/index.d.ts +10 -20
- package/dist/_vendor/ailf-core/examples/index.js +10 -20
- package/dist/_vendor/ailf-core/ports/context.d.ts +1 -1
- package/dist/_vendor/ailf-core/ports/task-source.d.ts +2 -0
- package/dist/_vendor/ailf-core/types/index.d.ts +19 -1
- package/dist/_vendor/ailf-tasks/schemas.d.ts +12 -0
- package/dist/_vendor/ailf-tasks/schemas.js +4 -0
- package/dist/adapters/task-sources/content-lake-task-source.js +9 -1
- package/dist/adapters/task-sources/repo-task-source.js +19 -4
- package/dist/commands/calculate-scores.js +5 -1
- package/dist/commands/publish.js +3 -0
- package/dist/orchestration/steps/calculate-scores-step.js +18 -19
- package/dist/orchestration/steps/publish-report-step.js +12 -1
- package/dist/pipeline/calculate-scores.d.ts +6 -1
- package/dist/pipeline/calculate-scores.js +5 -13
- package/dist/pipeline/compare.js +12 -5
- package/dist/pipeline/generate-configs.js +4 -9
- package/dist/pipeline/mirror-repo-tasks.d.ts +77 -0
- package/dist/pipeline/mirror-repo-tasks.js +141 -27
- package/dist/pipeline/pr-comment.js +5 -2
- package/dist/pipeline/release-report.js +4 -0
- package/dist/pipeline/report-title.d.ts +66 -0
- package/dist/pipeline/report-title.js +118 -0
- package/dist/report-store.d.ts +5 -1
- package/dist/report-store.js +31 -2
- package/dist/sinks/bigquery/index.d.ts +1 -0
- package/dist/sinks/bigquery/index.js +1 -0
- package/dist/sinks/slack/format.js +10 -0
- package/package.json +23 -23
|
@@ -63,8 +63,9 @@ export class CalculateScoresStep {
|
|
|
63
63
|
catch {
|
|
64
64
|
// Non-fatal — proceed without source metadata
|
|
65
65
|
}
|
|
66
|
+
let belowCritical = [];
|
|
66
67
|
try {
|
|
67
|
-
calculateAndWriteScores({
|
|
68
|
+
const result = calculateAndWriteScores({
|
|
68
69
|
allowedOrigins: ctx.config.allowedOrigins,
|
|
69
70
|
mode: ctx.config.mode,
|
|
70
71
|
resolvedSource,
|
|
@@ -75,25 +76,14 @@ export class CalculateScoresStep {
|
|
|
75
76
|
searchMode: ctx.config.searchMode,
|
|
76
77
|
source: ctx.config.source,
|
|
77
78
|
});
|
|
79
|
+
belowCritical = result.belowCritical;
|
|
78
80
|
}
|
|
79
81
|
catch (err) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
durationMs: Date.now() - start,
|
|
86
|
-
error: `calculate-scores failed with exit code ${code}`,
|
|
87
|
-
status: "failed",
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
if (code === undefined) {
|
|
91
|
-
return {
|
|
92
|
-
durationMs: Date.now() - start,
|
|
93
|
-
error: `calculate-scores failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
94
|
-
status: "failed",
|
|
95
|
-
};
|
|
96
|
-
}
|
|
82
|
+
return {
|
|
83
|
+
durationMs: Date.now() - start,
|
|
84
|
+
error: `calculate-scores failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
85
|
+
status: "failed",
|
|
86
|
+
};
|
|
97
87
|
}
|
|
98
88
|
// Postcondition: score summary exists and is valid
|
|
99
89
|
const summaryIssues = checkScoreSummaryValid(ctx.config.rootDir);
|
|
@@ -105,10 +95,19 @@ export class CalculateScoresStep {
|
|
|
105
95
|
status: "failed",
|
|
106
96
|
};
|
|
107
97
|
}
|
|
98
|
+
// Propagate belowCritical into pipeline state for downstream consumers
|
|
99
|
+
// (e.g., orchestrator reporting, publish step metadata).
|
|
100
|
+
// This is informational — the pipeline continues to run subsequent steps.
|
|
101
|
+
if (belowCritical.length > 0) {
|
|
102
|
+
state.belowCritical = belowCritical;
|
|
103
|
+
}
|
|
104
|
+
const criticalSuffix = belowCritical.length > 0
|
|
105
|
+
? ` (${belowCritical.length} area(s) below critical threshold: ${belowCritical.join(", ")})`
|
|
106
|
+
: "";
|
|
108
107
|
return {
|
|
109
108
|
durationMs: Date.now() - start,
|
|
110
109
|
status: "success",
|
|
111
|
-
summary:
|
|
110
|
+
summary: `Scores calculated and summary written${criticalSuffix}`,
|
|
112
111
|
};
|
|
113
112
|
}
|
|
114
113
|
cacheInputs(ctx) {
|
|
@@ -14,6 +14,7 @@ import { readFileSync } from "fs";
|
|
|
14
14
|
import { resolve } from "path";
|
|
15
15
|
import { checkScoreSummaryValid } from "../../pipeline/checks.js";
|
|
16
16
|
import { buildProvenance, } from "../../pipeline/provenance.js";
|
|
17
|
+
import { generateReportTitle } from "../../pipeline/report-title.js";
|
|
17
18
|
import { generateReportId } from "../../report-store.js";
|
|
18
19
|
import { withRetry } from "../../sinks/retry.js";
|
|
19
20
|
export class PublishReportStep {
|
|
@@ -82,8 +83,16 @@ export class PublishReportStep {
|
|
|
82
83
|
const durationMs = Date.now() - this.pipelineStart;
|
|
83
84
|
// Auto-compare against most recent comparable baseline.
|
|
84
85
|
// Returns the comparison + baseline report ID for lineage tracking.
|
|
86
|
+
//
|
|
87
|
+
// When release auto-scope is active, the current experiment only covers
|
|
88
|
+
// a subset of areas. We pass the evaluated area set so autoCompare can
|
|
89
|
+
// scope the baseline to match — preventing mismatched areas from
|
|
90
|
+
// polluting the overall delta.
|
|
91
|
+
const evaluatedAreas = state.releaseAutoScope
|
|
92
|
+
? new Set(summary.scores.map((s) => s.feature))
|
|
93
|
+
: undefined;
|
|
85
94
|
const autoCompareResult = ctx.reportStore
|
|
86
|
-
? (await ctx.reportStore.autoCompare(summary, provenance, now))
|
|
95
|
+
? (await ctx.reportStore.autoCompare(summary, provenance, now, evaluatedAreas))
|
|
87
96
|
: null;
|
|
88
97
|
const comparison = autoCompareResult?.comparison ?? null;
|
|
89
98
|
// Record which report we compared against in lineage
|
|
@@ -93,6 +102,7 @@ export class PublishReportStep {
|
|
|
93
102
|
comparedAgainst: autoCompareResult.baselineReportId,
|
|
94
103
|
};
|
|
95
104
|
}
|
|
105
|
+
const title = generateReportTitle({ provenance });
|
|
96
106
|
const report = {
|
|
97
107
|
comparison: comparison ?? undefined,
|
|
98
108
|
completedAt: now,
|
|
@@ -101,6 +111,7 @@ export class PublishReportStep {
|
|
|
101
111
|
provenance,
|
|
102
112
|
summary,
|
|
103
113
|
tag: this.options.publishTag ?? ctx.config.publishTag,
|
|
114
|
+
title,
|
|
104
115
|
};
|
|
105
116
|
// Share reportId with downstream steps (CallbackStep + orchestrator job update)
|
|
106
117
|
state.reportId = reportId;
|
|
@@ -99,4 +99,9 @@ export interface CalculateScoresOptions {
|
|
|
99
99
|
/** Documentation source name */
|
|
100
100
|
source?: string;
|
|
101
101
|
}
|
|
102
|
-
|
|
102
|
+
/** Result from calculateAndWriteScores — replaces process.exit() calls. */
|
|
103
|
+
export interface CalculateScoresResult {
|
|
104
|
+
/** Feature areas that scored below the critical threshold (40). */
|
|
105
|
+
belowCritical: string[];
|
|
106
|
+
}
|
|
107
|
+
export declare function calculateAndWriteScores(options: CalculateScoresOptions): CalculateScoresResult;
|
|
@@ -674,15 +674,10 @@ export function calculateAndWriteScores(options) {
|
|
|
674
674
|
const resultsIssues = checkResultsExist(ROOT, baselineResultsPath);
|
|
675
675
|
const resultsErrors = resultsIssues.filter((i) => i.severity === "error");
|
|
676
676
|
if (resultsErrors.length > 0) {
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
console.error(` at ${e.path}`);
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
console.error("\nRun 'pnpm eval' first to generate results, then 'pnpm calculate-scores'.");
|
|
685
|
-
process.exit(1);
|
|
677
|
+
const details = resultsErrors
|
|
678
|
+
.map((e) => (e.path ? `${e.message} (at ${e.path})` : e.message))
|
|
679
|
+
.join("; ");
|
|
680
|
+
throw new Error(`Results validation failed: ${details}. Run 'pnpm eval' first to generate results.`);
|
|
686
681
|
}
|
|
687
682
|
console.log(`Reading results from: ${baselineResultsPath}`);
|
|
688
683
|
if (source) {
|
|
@@ -750,10 +745,7 @@ export function calculateAndWriteScores(options) {
|
|
|
750
745
|
writeFileSync(join(outDir, "grader-judgments.json"), JSON.stringify(judgments, null, 2));
|
|
751
746
|
console.log(`Grader judgments written to results/latest/grader-judgments.json (${judgments.length} judgments)`);
|
|
752
747
|
}
|
|
753
|
-
|
|
754
|
-
if (summary.belowCritical.length > 0) {
|
|
755
|
-
process.exit(1);
|
|
756
|
-
}
|
|
748
|
+
return { belowCritical: summary.belowCritical };
|
|
757
749
|
}
|
|
758
750
|
function printPerModelReport(perModel) {
|
|
759
751
|
console.log("-".repeat(80));
|
package/dist/pipeline/compare.js
CHANGED
|
@@ -51,11 +51,14 @@ export function compare(baseline, experiment, options) {
|
|
|
51
51
|
// Identify mismatched areas
|
|
52
52
|
const onlyInBaseline = [...baselineAreas].filter((a) => !experimentAreas.has(a));
|
|
53
53
|
const onlyInExperiment = [...experimentAreas].filter((a) => !baselineAreas.has(a));
|
|
54
|
-
// Build per-area deltas
|
|
54
|
+
// Build per-area deltas.
|
|
55
|
+
// Areas present in only one summary get change: "not-evaluated" — comparing
|
|
56
|
+
// against a missing score is meaningless (it would produce false ±100 deltas).
|
|
57
|
+
const mismatchedSet = new Set([...onlyInBaseline, ...onlyInExperiment]);
|
|
55
58
|
const areas = [...allAreas]
|
|
56
59
|
.sort()
|
|
57
|
-
.map((area) => buildAreaDelta(area, findScore(baseline.scores, area), findScore(experiment.scores, area), threshold));
|
|
58
|
-
// Classify areas
|
|
60
|
+
.map((area) => buildAreaDelta(area, findScore(baseline.scores, area), findScore(experiment.scores, area), threshold, mismatchedSet.has(area)));
|
|
61
|
+
// Classify areas — mismatched areas are excluded from all three buckets
|
|
59
62
|
const improved = areas
|
|
60
63
|
.filter((a) => a.change === "improved")
|
|
61
64
|
.map((a) => a.area);
|
|
@@ -65,6 +68,9 @@ export function compare(baseline, experiment, options) {
|
|
|
65
68
|
const unchanged = areas
|
|
66
69
|
.filter((a) => a.change === "unchanged")
|
|
67
70
|
.map((a) => a.area);
|
|
71
|
+
const notEvaluated = areas
|
|
72
|
+
.filter((a) => a.change === "not-evaluated")
|
|
73
|
+
.map((a) => a.area);
|
|
68
74
|
// Per-area deltas as a record
|
|
69
75
|
const perArea = {};
|
|
70
76
|
for (const a of areas) {
|
|
@@ -128,12 +134,13 @@ export function compare(baseline, experiment, options) {
|
|
|
128
134
|
},
|
|
129
135
|
noiseThreshold: threshold,
|
|
130
136
|
noiseThresholdEmpirical: empirical,
|
|
137
|
+
notEvaluated,
|
|
131
138
|
regressed,
|
|
132
139
|
unchanged,
|
|
133
140
|
};
|
|
134
141
|
}
|
|
135
142
|
/** Build an AreaDelta from baseline and experiment scores for a single area */
|
|
136
|
-
function buildAreaDelta(area, baselineScore, experimentScore, threshold) {
|
|
143
|
+
function buildAreaDelta(area, baselineScore, experimentScore, threshold, isMismatched = false) {
|
|
137
144
|
const b = baselineScore;
|
|
138
145
|
const e = experimentScore;
|
|
139
146
|
const bTotal = b?.totalScore ?? 0;
|
|
@@ -174,7 +181,7 @@ function buildAreaDelta(area, baselineScore, experimentScore, threshold) {
|
|
|
174
181
|
area,
|
|
175
182
|
baseline: bTotal,
|
|
176
183
|
ceilingDelta: eCeiling - bCeiling,
|
|
177
|
-
change: classifyChange(delta, threshold),
|
|
184
|
+
change: isMismatched ? "not-evaluated" : classifyChange(delta, threshold),
|
|
178
185
|
delta,
|
|
179
186
|
dimensions: {
|
|
180
187
|
codeCorrectness: {
|
|
@@ -264,15 +264,10 @@ export function generateConfigs(options) {
|
|
|
264
264
|
const modelIssues = validateModelsYaml(rootDir);
|
|
265
265
|
const modelErrors = modelIssues.filter((i) => i.severity === "error");
|
|
266
266
|
if (modelErrors.length > 0) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
console.error(` at ${e.path}`);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
console.error("\nFix config/models.yaml before generating configs. Run 'pnpm validate' for details.");
|
|
275
|
-
process.exit(1);
|
|
267
|
+
const details = modelErrors
|
|
268
|
+
.map((e) => (e.path ? `${e.message} (at ${e.path})` : e.message))
|
|
269
|
+
.join("; ");
|
|
270
|
+
throw new Error(`config/models.yaml validation failed: ${details}. Run 'pnpm validate' for details.`);
|
|
276
271
|
}
|
|
277
272
|
console.log("Loading config/models.yaml...");
|
|
278
273
|
const models = loadModels(rootDir);
|
|
@@ -24,6 +24,15 @@ export interface MirrorOptions {
|
|
|
24
24
|
/** If true, log what would be done without writing */
|
|
25
25
|
dryRun?: boolean;
|
|
26
26
|
}
|
|
27
|
+
/** Authorship info extracted from git context or GitHub Actions environment. */
|
|
28
|
+
export interface GitAuthor {
|
|
29
|
+
/** Git commit author name (e.g., "Jordan Smith") */
|
|
30
|
+
gitName?: string;
|
|
31
|
+
/** Git commit author email (e.g., "jordan@example.com") */
|
|
32
|
+
gitEmail?: string;
|
|
33
|
+
/** GitHub username (from GITHUB_ACTOR or event payload) */
|
|
34
|
+
githubUsername?: string;
|
|
35
|
+
}
|
|
27
36
|
export interface GitContext {
|
|
28
37
|
/** Full repo identifier (e.g., "sanity-io/visual-editing") */
|
|
29
38
|
repo: string;
|
|
@@ -35,6 +44,8 @@ export interface GitContext {
|
|
|
35
44
|
branch: string;
|
|
36
45
|
/** HEAD commit SHA */
|
|
37
46
|
commitSha: string;
|
|
47
|
+
/** Author of the current commit/trigger */
|
|
48
|
+
author: GitAuthor;
|
|
38
49
|
}
|
|
39
50
|
export interface MirrorResult {
|
|
40
51
|
/** Total tasks processed */
|
|
@@ -84,3 +95,69 @@ export declare function mirrorDocId(owner: string, repo: string, taskId: string)
|
|
|
84
95
|
* that's not mirrored.
|
|
85
96
|
*/
|
|
86
97
|
export declare function computeTaskHash(task: TaskDefinition): string;
|
|
98
|
+
/** @internal Exported for testing — not part of the public API. */
|
|
99
|
+
export declare function buildMirrorDocument(task: TaskDefinition, opts: {
|
|
100
|
+
contentHash: string;
|
|
101
|
+
docId: string;
|
|
102
|
+
/** Existing author from the current mirror document (write-once preservation) */
|
|
103
|
+
existingAuthor?: GitAuthor;
|
|
104
|
+
git: GitContext;
|
|
105
|
+
slugToDocId: Map<string, string>;
|
|
106
|
+
}): {
|
|
107
|
+
baseline?: {
|
|
108
|
+
rubric?: "full" | "abbreviated" | "none" | undefined;
|
|
109
|
+
enabled?: boolean | undefined;
|
|
110
|
+
} | undefined;
|
|
111
|
+
_id: string;
|
|
112
|
+
_type: string;
|
|
113
|
+
ownership: string;
|
|
114
|
+
status: "active" | "draft" | "paused" | "archived";
|
|
115
|
+
assert: Record<string, unknown>[];
|
|
116
|
+
canonicalDocs: ({
|
|
117
|
+
_key: string;
|
|
118
|
+
reason: string;
|
|
119
|
+
} | {
|
|
120
|
+
refType: string;
|
|
121
|
+
path: string;
|
|
122
|
+
_key: string;
|
|
123
|
+
reason: string;
|
|
124
|
+
} | {
|
|
125
|
+
doc?: {
|
|
126
|
+
_ref: string;
|
|
127
|
+
_type: string;
|
|
128
|
+
} | undefined;
|
|
129
|
+
docId?: string | undefined;
|
|
130
|
+
refType: string;
|
|
131
|
+
_key: string;
|
|
132
|
+
reason: string;
|
|
133
|
+
} | {
|
|
134
|
+
refType: string;
|
|
135
|
+
perspective: string;
|
|
136
|
+
_key: string;
|
|
137
|
+
reason: string;
|
|
138
|
+
})[];
|
|
139
|
+
description: string;
|
|
140
|
+
docCoverage: boolean;
|
|
141
|
+
featureArea: {
|
|
142
|
+
_ref: string;
|
|
143
|
+
_type: string;
|
|
144
|
+
};
|
|
145
|
+
id: {
|
|
146
|
+
_type: string;
|
|
147
|
+
current: string;
|
|
148
|
+
};
|
|
149
|
+
origin: {
|
|
150
|
+
branch: string;
|
|
151
|
+
commitSha: string;
|
|
152
|
+
contentHash: string;
|
|
153
|
+
lastSyncedAt: string;
|
|
154
|
+
path: string;
|
|
155
|
+
repo: string;
|
|
156
|
+
repoName: string;
|
|
157
|
+
repoOwner: string;
|
|
158
|
+
type: string;
|
|
159
|
+
author: GitAuthor;
|
|
160
|
+
lastEditor: GitAuthor;
|
|
161
|
+
};
|
|
162
|
+
taskPrompt: string;
|
|
163
|
+
};
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* @see docs/exec-plans/tasks-as-content/phase-5-content-lake-mirroring.md
|
|
14
14
|
*/
|
|
15
15
|
import { createHash } from "crypto";
|
|
16
|
-
import {
|
|
16
|
+
import { readFileSync } from "fs";
|
|
17
|
+
import { isIdRef, isPathRef, isPerspectiveRef, isSlugRef, } from "../_vendor/ailf-core/index.js";
|
|
17
18
|
// ---------------------------------------------------------------------------
|
|
18
19
|
// Public API
|
|
19
20
|
// ---------------------------------------------------------------------------
|
|
@@ -56,22 +57,30 @@ export async function mirrorRepoTasks(options) {
|
|
|
56
57
|
const areas = [...new Set(tasks.map((t) => t.featureArea))];
|
|
57
58
|
const createdAreas = await ensureFeatureAreas(client, areas, dryRun);
|
|
58
59
|
result.areasCreated = createdAreas;
|
|
59
|
-
// Fetch existing mirror document
|
|
60
|
+
// Fetch existing mirror document state for change detection + ownership check
|
|
60
61
|
const mirrorIds = tasks.map((t) => mirrorDocId(git.owner, git.name, t.id));
|
|
61
|
-
const
|
|
62
|
+
const existingDocState = await fetchExistingDocState(client, mirrorIds);
|
|
62
63
|
// Mirror each task
|
|
63
64
|
for (const task of tasks) {
|
|
64
65
|
try {
|
|
65
66
|
const docId = mirrorDocId(git.owner, git.name, task.id);
|
|
67
|
+
const existing = existingDocState.get(docId);
|
|
68
|
+
// Skip graduated tasks — ownership was changed to "studio"
|
|
69
|
+
if (existing?.ownership === "studio") {
|
|
70
|
+
console.log(` ℹ️ Skipping "${task.id}" — graduated to Studio ownership`);
|
|
71
|
+
result.skipped++;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
66
74
|
const contentHash = computeTaskHash(task);
|
|
67
75
|
// Skip unchanged
|
|
68
|
-
if (
|
|
76
|
+
if (existing?.hash === contentHash) {
|
|
69
77
|
result.skipped++;
|
|
70
78
|
continue;
|
|
71
79
|
}
|
|
72
80
|
const doc = buildMirrorDocument(task, {
|
|
73
81
|
contentHash,
|
|
74
82
|
docId,
|
|
83
|
+
existingAuthor: existing?.existingAuthor,
|
|
75
84
|
git,
|
|
76
85
|
slugToDocId,
|
|
77
86
|
});
|
|
@@ -106,12 +115,15 @@ export async function detectGitContext(repoTasksPath) {
|
|
|
106
115
|
if (ghRepo) {
|
|
107
116
|
const [owner, name] = ghRepo.split("/");
|
|
108
117
|
const branch = ghHeadRef || ghRef.replace("refs/heads/", "").replace("refs/tags/", "");
|
|
118
|
+
// Extract author from GitHub Actions environment
|
|
119
|
+
const author = detectGitHubActionsAuthor();
|
|
109
120
|
return {
|
|
110
121
|
repo: ghRepo,
|
|
111
122
|
owner: owner ?? "unknown",
|
|
112
123
|
name: name ?? "unknown",
|
|
113
124
|
branch: branch || "unknown",
|
|
114
125
|
commitSha: ghSha || "unknown",
|
|
126
|
+
author,
|
|
115
127
|
};
|
|
116
128
|
}
|
|
117
129
|
// Fallback: try git CLI
|
|
@@ -134,12 +146,15 @@ export async function detectGitContext(repoTasksPath) {
|
|
|
134
146
|
remote.match(/([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
135
147
|
const owner = match?.[1] ?? "unknown";
|
|
136
148
|
const name = match?.[2] ?? "unknown";
|
|
149
|
+
// Extract author from git log
|
|
150
|
+
const author = detectGitCliAuthor(repoTasksPath, execSync);
|
|
137
151
|
return {
|
|
138
152
|
repo: `${owner}/${name}`,
|
|
139
153
|
owner,
|
|
140
154
|
name,
|
|
141
155
|
branch,
|
|
142
156
|
commitSha,
|
|
157
|
+
author,
|
|
143
158
|
};
|
|
144
159
|
}
|
|
145
160
|
catch {
|
|
@@ -149,10 +164,63 @@ export async function detectGitContext(repoTasksPath) {
|
|
|
149
164
|
name: "unknown",
|
|
150
165
|
branch: "unknown",
|
|
151
166
|
commitSha: "unknown",
|
|
167
|
+
author: {},
|
|
152
168
|
};
|
|
153
169
|
}
|
|
154
170
|
}
|
|
155
171
|
// ---------------------------------------------------------------------------
|
|
172
|
+
// Author detection helpers
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
/**
|
|
175
|
+
* Extract author info from GitHub Actions environment variables and
|
|
176
|
+
* the webhook event payload (GITHUB_EVENT_PATH).
|
|
177
|
+
*/
|
|
178
|
+
function detectGitHubActionsAuthor() {
|
|
179
|
+
const ghActor = process.env.GITHUB_ACTOR ?? undefined;
|
|
180
|
+
const author = { githubUsername: ghActor };
|
|
181
|
+
// Try to read richer author info from the event payload
|
|
182
|
+
const eventPath = process.env.GITHUB_EVENT_PATH;
|
|
183
|
+
if (eventPath) {
|
|
184
|
+
try {
|
|
185
|
+
const event = JSON.parse(readFileSync(eventPath, "utf-8"));
|
|
186
|
+
// Push event: head_commit.author has name + email
|
|
187
|
+
if (event.head_commit?.author) {
|
|
188
|
+
author.gitName = event.head_commit.author.name ?? undefined;
|
|
189
|
+
author.gitEmail = event.head_commit.author.email ?? undefined;
|
|
190
|
+
}
|
|
191
|
+
// PR event: pull_request.user.login is the PR author
|
|
192
|
+
if (event.pull_request?.user?.login) {
|
|
193
|
+
author.githubUsername = event.pull_request.user.login;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// Event payload parsing is best-effort — fall through with GITHUB_ACTOR only
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return author;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Extract author info from git CLI (local fallback when not in GitHub Actions).
|
|
204
|
+
*/
|
|
205
|
+
function detectGitCliAuthor(cwd, execSyncFn) {
|
|
206
|
+
try {
|
|
207
|
+
const authorLine = execSyncFn('git log -1 --format="%an|%ae"', {
|
|
208
|
+
encoding: "utf-8",
|
|
209
|
+
cwd,
|
|
210
|
+
})
|
|
211
|
+
.toString()
|
|
212
|
+
.trim();
|
|
213
|
+
const [gitName, gitEmail] = authorLine.split("|");
|
|
214
|
+
return {
|
|
215
|
+
gitName: gitName || undefined,
|
|
216
|
+
gitEmail: gitEmail || undefined,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return {};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
156
224
|
// Document ID scheme
|
|
157
225
|
// ---------------------------------------------------------------------------
|
|
158
226
|
/**
|
|
@@ -247,42 +315,82 @@ async function ensureFeatureAreas(client, areas, dryRun) {
|
|
|
247
315
|
await transaction.commit();
|
|
248
316
|
return missing;
|
|
249
317
|
}
|
|
250
|
-
// ---------------------------------------------------------------------------
|
|
251
|
-
// Fetch existing content hashes
|
|
252
|
-
// ---------------------------------------------------------------------------
|
|
253
318
|
/**
|
|
254
|
-
* Fetch existing mirror documents' content hashes
|
|
255
|
-
*
|
|
319
|
+
* Fetch existing mirror documents' ownership and content hashes.
|
|
320
|
+
*
|
|
321
|
+
* The ownership field determines whether the mirror step is allowed to
|
|
322
|
+
* update the document:
|
|
323
|
+
* - `"repo"` or absent → safe to update (active mirror)
|
|
324
|
+
* - `"studio"` → skip (graduated to Studio ownership)
|
|
325
|
+
*
|
|
326
|
+
* The hash is stored in origin.contentHash for change detection.
|
|
256
327
|
*/
|
|
257
|
-
async function
|
|
328
|
+
async function fetchExistingDocState(client, docIds) {
|
|
258
329
|
if (docIds.length === 0)
|
|
259
330
|
return new Map();
|
|
260
|
-
const query = `*[_id in $ids] {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
331
|
+
const query = `*[_id in $ids] {
|
|
332
|
+
_id,
|
|
333
|
+
"hash": origin.contentHash,
|
|
334
|
+
ownership,
|
|
335
|
+
"existingAuthor": origin.author
|
|
336
|
+
}`;
|
|
337
|
+
const results = await client.fetch(query, { ids: docIds });
|
|
264
338
|
const map = new Map();
|
|
265
339
|
for (const r of results) {
|
|
266
|
-
|
|
267
|
-
|
|
340
|
+
map.set(r._id, {
|
|
341
|
+
hash: r.hash ?? undefined,
|
|
342
|
+
ownership: r.ownership ?? undefined,
|
|
343
|
+
existingAuthor: r.existingAuthor ?? undefined,
|
|
344
|
+
});
|
|
268
345
|
}
|
|
269
346
|
return map;
|
|
270
347
|
}
|
|
271
348
|
// ---------------------------------------------------------------------------
|
|
272
349
|
// Build mirror document
|
|
273
350
|
// ---------------------------------------------------------------------------
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
//
|
|
278
|
-
//
|
|
351
|
+
/** @internal Exported for testing — not part of the public API. */
|
|
352
|
+
export function buildMirrorDocument(task, opts) {
|
|
353
|
+
const { contentHash, docId, existingAuthor, git, slugToDocId } = opts;
|
|
354
|
+
// Build canonical docs with resolved references and correct refType.
|
|
355
|
+
// Each ref type gets the appropriate resolution fields set on the
|
|
356
|
+
// mirror document so Studio can display them correctly.
|
|
279
357
|
const canonicalDocs = task.canonicalDocs.map((ref, i) => {
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
358
|
+
const base = { _key: `cd${i}`, reason: ref.reason ?? "" };
|
|
359
|
+
if (isSlugRef(ref)) {
|
|
360
|
+
const resolvedId = slugToDocId.get(ref.slug);
|
|
361
|
+
// When a slug resolves to a document, store as "id" ref with
|
|
362
|
+
// the resolved article reference. When unresolved, store as
|
|
363
|
+
// "slug" so Studio knows the resolution strategy even if the
|
|
364
|
+
// article doesn't exist yet.
|
|
365
|
+
return {
|
|
366
|
+
...base,
|
|
367
|
+
refType: resolvedId ? "id" : "slug",
|
|
368
|
+
...(resolvedId
|
|
369
|
+
? { doc: { _ref: resolvedId, _type: "reference" } }
|
|
370
|
+
: {}),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
if (isPathRef(ref)) {
|
|
374
|
+
return { ...base, refType: "path", path: ref.path };
|
|
375
|
+
}
|
|
376
|
+
if (isIdRef(ref)) {
|
|
377
|
+
return {
|
|
378
|
+
...base,
|
|
379
|
+
refType: "id",
|
|
380
|
+
...(ref.id
|
|
381
|
+
? { doc: { _ref: ref.id, _type: "reference" }, docId: ref.id }
|
|
382
|
+
: {}),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
if (isPerspectiveRef(ref)) {
|
|
386
|
+
return {
|
|
387
|
+
...base,
|
|
388
|
+
refType: "perspective",
|
|
389
|
+
perspective: ref.perspective,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
// Unknown ref type — store with reason only
|
|
393
|
+
return base;
|
|
286
394
|
});
|
|
287
395
|
// Build assertions
|
|
288
396
|
const assertArray = task.assertions.map((a, i) => {
|
|
@@ -315,6 +423,8 @@ function buildMirrorDocument(task, opts) {
|
|
|
315
423
|
return {
|
|
316
424
|
_id: docId,
|
|
317
425
|
_type: "ailf.task",
|
|
426
|
+
ownership: "repo",
|
|
427
|
+
status: task.status ?? "active",
|
|
318
428
|
assert: assertArray,
|
|
319
429
|
canonicalDocs,
|
|
320
430
|
description: task.description,
|
|
@@ -325,6 +435,7 @@ function buildMirrorDocument(task, opts) {
|
|
|
325
435
|
},
|
|
326
436
|
id: { _type: "slug", current: task.id },
|
|
327
437
|
origin: {
|
|
438
|
+
// Existing provenance fields
|
|
328
439
|
branch: git.branch,
|
|
329
440
|
commitSha: git.commitSha,
|
|
330
441
|
contentHash,
|
|
@@ -334,6 +445,9 @@ function buildMirrorDocument(task, opts) {
|
|
|
334
445
|
repoName: git.name,
|
|
335
446
|
repoOwner: git.owner,
|
|
336
447
|
type: "repo",
|
|
448
|
+
// Authorship: author is write-once (preserve existing), lastEditor always updates
|
|
449
|
+
author: existingAuthor ?? git.author,
|
|
450
|
+
lastEditor: git.author,
|
|
337
451
|
},
|
|
338
452
|
taskPrompt: task.taskPrompt,
|
|
339
453
|
...(task.baseline
|
|
@@ -295,7 +295,7 @@ function generateComment(summary, options = {}) {
|
|
|
295
295
|
if (hasActualDeltas) {
|
|
296
296
|
lines.push("| Feature | Baseline | Current | Delta | Actual Δ | Ret. Gap Δ | Infra Δ |");
|
|
297
297
|
lines.push("|---------|----------|---------|-------|----------|------------|---------|");
|
|
298
|
-
for (const a of report.areas) {
|
|
298
|
+
for (const a of report.areas.filter((a) => a.change !== "not-evaluated")) {
|
|
299
299
|
const icon = a.change === "improved"
|
|
300
300
|
? "📈"
|
|
301
301
|
: a.change === "regressed"
|
|
@@ -313,7 +313,7 @@ function generateComment(summary, options = {}) {
|
|
|
313
313
|
else {
|
|
314
314
|
lines.push("| Feature | Baseline | Current | Delta | Task | Code | Docs |");
|
|
315
315
|
lines.push("|---------|----------|---------|-------|------|------|------|");
|
|
316
|
-
for (const a of report.areas) {
|
|
316
|
+
for (const a of report.areas.filter((a) => a.change !== "not-evaluated")) {
|
|
317
317
|
const icon = a.change === "improved"
|
|
318
318
|
? "📈"
|
|
319
319
|
: a.change === "regressed"
|
|
@@ -334,6 +334,9 @@ function generateComment(summary, options = {}) {
|
|
|
334
334
|
if (report.unchanged.length > 0) {
|
|
335
335
|
parts.push(`➡️ ${report.unchanged.length} unchanged`);
|
|
336
336
|
}
|
|
337
|
+
if (report.notEvaluated?.length > 0) {
|
|
338
|
+
parts.push(`⏭️ ${report.notEvaluated.length} not evaluated`);
|
|
339
|
+
}
|
|
337
340
|
if (parts.length > 0) {
|
|
338
341
|
const isEmpirical = "noiseThresholdEmpirical" in report &&
|
|
339
342
|
report.noiseThresholdEmpirical === true;
|
|
@@ -36,6 +36,10 @@ export function buildReleaseImpactReport(classification, comparison, attribution
|
|
|
36
36
|
const confirmedUnchanged = [];
|
|
37
37
|
if (comparison) {
|
|
38
38
|
for (const areaDelta of comparison.areas) {
|
|
39
|
+
// Skip areas that weren't evaluated in both runs — these are
|
|
40
|
+
// mismatched areas (e.g., auto-scoped release eval vs full baseline).
|
|
41
|
+
if (areaDelta.change === "not-evaluated")
|
|
42
|
+
continue;
|
|
39
43
|
const regressed = areaDelta.delta < -threshold;
|
|
40
44
|
// Find tasks and their attributed documents for this area
|
|
41
45
|
const areaTasks = [];
|