ai-foreman 1.7.0 → 1.7.2
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 +1 -1
- package/dist/branch/git.d.ts +1 -0
- package/dist/branch/git.js +5 -2
- package/dist/branch/runner.d.ts +2 -0
- package/dist/branch/runner.js +14 -1
- package/dist/cli/start.js +15 -5
- package/dist/cli/tickets.js +46 -2
- package/dist/sources/sourceRegistry.d.ts +49 -0
- package/dist/sources/sourceRegistry.js +577 -0
- package/dist/ticketPopulation.d.ts +7 -0
- package/dist/ticketPopulation.js +4 -0
- package/dist/tickets/commands.js +1 -0
- package/dist/tickets/queue.d.ts +1 -1
- package/dist/tickets/queue.js +17 -6
- package/dist/tickets/setupConfig.d.ts +1 -1
- package/dist/tickets/setupConfig.js +31 -3
- package/dist/tickets/sourceFetch.js +6 -1
- package/dist/tickets/ticketSchema.d.ts +9 -0
- package/dist/tickets/ticketSchema.js +1 -0
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -176,7 +176,7 @@ gh repo view <host>/<owner>/<repo>
|
|
|
176
176
|
| `--fast` | flag | off | Lower latency. |
|
|
177
177
|
| `-y, --yes` | flag | off | Skips confirmation before builder edits ticket files. |
|
|
178
178
|
|
|
179
|
-
Use `ai-foreman tickets setup:init` or `setup:update` to
|
|
179
|
+
Use `ai-foreman tickets setup:init` or `setup:update` to append local, Linear, or Jira Cloud sources to the project-wide registry in `rafi-config.yaml`. Linear imports use `LINEAR_API_KEY`; Jira Cloud imports use `JIRA_EMAIL` and `JIRA_API_TOKEN`. Credentials are never persisted, and mirrored tickets retain source provenance.
|
|
180
180
|
|
|
181
181
|
Use `ai-foreman tickets review` to accept, defer, or dismiss pending split/combine/duplicate recommendations rendered in the progress doc.
|
|
182
182
|
|
package/dist/branch/git.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export declare function generatedTrackerDirtyPaths(paths: {
|
|
|
11
11
|
progressDoc: string;
|
|
12
12
|
archiveDoc?: string;
|
|
13
13
|
}): string[];
|
|
14
|
+
export declare function collectBaseWorktreeDirtyPaths(cwd: string, opts?: BaseWorktreeCleanOptions): string[];
|
|
14
15
|
export declare function ensureCleanBaseWorktree(cwd: string, opts?: BaseWorktreeCleanOptions): void;
|
|
15
16
|
export declare function ensureForemanExcluded(cwd: string): void;
|
|
16
17
|
export declare function createTicketWorktree(projectDir: string, runId: string, branch: string, base: string): string;
|
package/dist/branch/git.js
CHANGED
|
@@ -25,13 +25,16 @@ export function generatedTrackerDirtyPaths(paths) {
|
|
|
25
25
|
}
|
|
26
26
|
return out;
|
|
27
27
|
}
|
|
28
|
-
export function
|
|
28
|
+
export function collectBaseWorktreeDirtyPaths(cwd, opts = {}) {
|
|
29
29
|
const allowedDirtyPaths = new Set([".tickets/.gitignore", ...(opts.allowedDirtyPaths ?? []).map(normalizeRepoPath)]);
|
|
30
30
|
const status = runGit(cwd, ["status", "--porcelain", "--untracked-files=all"]).stdout;
|
|
31
|
-
|
|
31
|
+
return status.split("\n").filter(Boolean).filter((line) => {
|
|
32
32
|
const path = normalizeRepoPath(line.slice(3));
|
|
33
33
|
return !path.startsWith(".foreman/") && !allowedDirtyPaths.has(path);
|
|
34
34
|
});
|
|
35
|
+
}
|
|
36
|
+
export function ensureCleanBaseWorktree(cwd, opts = {}) {
|
|
37
|
+
const dirty = collectBaseWorktreeDirtyPaths(cwd, opts);
|
|
35
38
|
if (dirty.length > 0) {
|
|
36
39
|
throw new Error(`base worktree has uncommitted changes:\n${dirty.join("\n")}`);
|
|
37
40
|
}
|
package/dist/branch/runner.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface DeliveryUnitSession {
|
|
|
10
10
|
sessionId: string;
|
|
11
11
|
ticket: string;
|
|
12
12
|
}
|
|
13
|
+
export type BaseWorktreePolicy = "enforce" | "warn" | "skip";
|
|
13
14
|
export declare function readDeliveryUnitSession(projectDir: string, unitId: string): DeliveryUnitSession | undefined;
|
|
14
15
|
export interface BranchRunnerOptions {
|
|
15
16
|
projectDir: string;
|
|
@@ -32,6 +33,7 @@ export interface BranchRunnerOptions {
|
|
|
32
33
|
autoMergeTimeoutMinutes?: number | null;
|
|
33
34
|
mergeMethod?: MergeMethod;
|
|
34
35
|
allowedBaseDirtyPaths?: string[];
|
|
36
|
+
baseWorktreePolicy?: BaseWorktreePolicy;
|
|
35
37
|
trackerPaths?: {
|
|
36
38
|
progressDoc: string;
|
|
37
39
|
archiveDoc: string;
|
package/dist/branch/runner.js
CHANGED
|
@@ -24,7 +24,20 @@ function deliverySessionPath(projectDir, unitId) {
|
|
|
24
24
|
return join(projectDir, ".foreman", "delivery-sessions", `${unitId}.json`);
|
|
25
25
|
}
|
|
26
26
|
export async function runBranchPlan(opts) {
|
|
27
|
-
|
|
27
|
+
const baseWorktreePolicy = opts.baseWorktreePolicy ?? "enforce";
|
|
28
|
+
if (baseWorktreePolicy !== "skip") {
|
|
29
|
+
try {
|
|
30
|
+
ensureCleanBaseWorktree(opts.projectDir, { allowedDirtyPaths: opts.allowedBaseDirtyPaths });
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (baseWorktreePolicy === "warn") {
|
|
34
|
+
console.warn(`foreman: warning: ${error instanceof Error ? error.message : String(error)}`);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
28
41
|
ensureForemanExcluded(opts.projectDir);
|
|
29
42
|
const completionMode = opts.completionMode ?? (opts.createPr ? "pr" : "none");
|
|
30
43
|
const reviewProvider = opts.reviewProvider ?? "github";
|
package/dist/cli/start.js
CHANGED
|
@@ -18,7 +18,7 @@ import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "../
|
|
|
18
18
|
import { loadTickets } from "../tickets/ticketLoader.js";
|
|
19
19
|
import { StateDb } from "../tickets/stateDb.js";
|
|
20
20
|
import { applySharedDeliveryBranch, buildBranchAuditInstruction, buildBranchPlan, parseAuditDependencies } from "../branch/planner.js";
|
|
21
|
-
import { currentGitRef, ensureCleanBaseWorktree, generatedTrackerDirtyPaths } from "../branch/git.js";
|
|
21
|
+
import { collectBaseWorktreeDirtyPaths, currentGitRef, ensureCleanBaseWorktree, generatedTrackerDirtyPaths } from "../branch/git.js";
|
|
22
22
|
import { formatGitHubFailure, preflightGh } from "../branch/github.js";
|
|
23
23
|
import { preflightGlab } from "../branch/gitlab.js";
|
|
24
24
|
import { readDeliveryUnitSession, runBranchPlan } from "../branch/runner.js";
|
|
@@ -529,11 +529,20 @@ export function buildStartCommand() {
|
|
|
529
529
|
if (branchMode) {
|
|
530
530
|
const ticketsConfig = loadTicketsConfig(cwd);
|
|
531
531
|
const allowedBaseDirtyPaths = generatedTrackerDirtyPaths(ticketsConfig.paths);
|
|
532
|
-
|
|
533
|
-
|
|
532
|
+
const baseWorktreePolicy = recoveryRecord ? "skip" : "enforce";
|
|
533
|
+
if (baseWorktreePolicy === "skip") {
|
|
534
|
+
const dirty = collectBaseWorktreeDirtyPaths(cwd, { allowedDirtyPaths: allowedBaseDirtyPaths });
|
|
535
|
+
if (dirty.length > 0) {
|
|
536
|
+
console.warn(`foreman: warning: base worktree has uncommitted changes; build recovery will continue.\n${dirty.join("\n")}`);
|
|
537
|
+
}
|
|
534
538
|
}
|
|
535
|
-
|
|
536
|
-
|
|
539
|
+
else {
|
|
540
|
+
try {
|
|
541
|
+
ensureCleanBaseWorktree(cwd, { allowedDirtyPaths: allowedBaseDirtyPaths });
|
|
542
|
+
}
|
|
543
|
+
catch (err) {
|
|
544
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
545
|
+
}
|
|
537
546
|
}
|
|
538
547
|
if (branchDefaults.createReview && branchDefaults.reviewProvider) {
|
|
539
548
|
await ensureReviewProviderReady(cwd, branchDefaults.reviewProvider, log, Boolean(opts.yes));
|
|
@@ -718,6 +727,7 @@ export function buildStartCommand() {
|
|
|
718
727
|
autoMergeTimeoutMinutes: branchDefaults.autoMergeTimeoutMinutes,
|
|
719
728
|
mergeMethod: branchDefaults.mergeMethod,
|
|
720
729
|
allowedBaseDirtyPaths,
|
|
730
|
+
baseWorktreePolicy,
|
|
721
731
|
trackerPaths: {
|
|
722
732
|
progressDoc: ticketsConfig.paths.progressDoc,
|
|
723
733
|
archiveDoc: ticketsConfig.paths.archiveDoc,
|
package/dist/cli/tickets.js
CHANGED
|
@@ -266,6 +266,8 @@ async function collectTicketSetupPatch(dir, opts, current) {
|
|
|
266
266
|
const buildPatch = {};
|
|
267
267
|
if (opts.agentPreference)
|
|
268
268
|
populatePatch.agent_preference = parseAgentPreference(opts.agentPreference);
|
|
269
|
+
if (opts.branchStrategy)
|
|
270
|
+
buildPatch.branch_strategy = parseBranchStrategy(opts.branchStrategy);
|
|
269
271
|
if (opts.completion)
|
|
270
272
|
buildPatch.completion = parseCompletionMode(opts.completion);
|
|
271
273
|
if (opts.provider)
|
|
@@ -275,6 +277,15 @@ async function collectTicketSetupPatch(dir, opts, current) {
|
|
|
275
277
|
if (opts.autoMergeTimeoutMinutes !== undefined) {
|
|
276
278
|
buildPatch.auto_merge_timeout_minutes = parseOptionalPositiveInteger(opts.autoMergeTimeoutMinutes, "--auto-merge-timeout-minutes");
|
|
277
279
|
}
|
|
280
|
+
if (buildPatch.branch_strategy === "current") {
|
|
281
|
+
if (buildPatch.completion && buildPatch.completion !== "none")
|
|
282
|
+
fail("--branch-strategy current requires --completion none");
|
|
283
|
+
buildPatch.completion = "none";
|
|
284
|
+
buildPatch.provider = buildPatch.provider ?? "local";
|
|
285
|
+
buildPatch.pr_ready = false;
|
|
286
|
+
buildPatch.auto_merge_wait = false;
|
|
287
|
+
buildPatch.auto_merge_timeout_minutes = null;
|
|
288
|
+
}
|
|
278
289
|
if (!shouldPrompt(opts)) {
|
|
279
290
|
const prefill = current.sources.length === 0
|
|
280
291
|
? configuredPlanningSources(dir).map((path) => ({ type: "local", paths: [path] }))
|
|
@@ -321,6 +332,32 @@ async function collectTicketSetupPatch(dir, opts, current) {
|
|
|
321
332
|
}
|
|
322
333
|
if (section === "build" || section === "all") {
|
|
323
334
|
const recommended = recommendedBuildDefaults(dir);
|
|
335
|
+
const strategy = await select({
|
|
336
|
+
message: "Default ticket work mode:",
|
|
337
|
+
initialValue: current.build.branch_strategy,
|
|
338
|
+
options: [
|
|
339
|
+
{ value: "current", label: "One branch - work the queue on the current branch" },
|
|
340
|
+
{ value: "batch", label: "Batch branch - use shared branches for explicit delivery batches" },
|
|
341
|
+
{ value: "branch-per-ticket", label: "Branch per ticket - isolate each ticket on its own branch" },
|
|
342
|
+
],
|
|
343
|
+
});
|
|
344
|
+
if (isCancel(strategy))
|
|
345
|
+
process.exit(0);
|
|
346
|
+
buildPatch.branch_strategy = strategy;
|
|
347
|
+
if (strategy === "current") {
|
|
348
|
+
buildPatch.completion = "none";
|
|
349
|
+
buildPatch.provider = "local";
|
|
350
|
+
buildPatch.pr_ready = false;
|
|
351
|
+
buildPatch.merge_method = "squash";
|
|
352
|
+
buildPatch.cleanup = true;
|
|
353
|
+
buildPatch.auto_merge_wait = false;
|
|
354
|
+
buildPatch.auto_merge_timeout_minutes = null;
|
|
355
|
+
return {
|
|
356
|
+
sources,
|
|
357
|
+
populate: populatePatch,
|
|
358
|
+
build: buildPatch,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
324
361
|
const completion = await select({
|
|
325
362
|
message: "Default completion behavior for branch ticket runs:",
|
|
326
363
|
initialValue: current.build.completion === "none" ? recommended.completion : current.build.completion,
|
|
@@ -333,7 +370,6 @@ async function collectTicketSetupPatch(dir, opts, current) {
|
|
|
333
370
|
});
|
|
334
371
|
if (isCancel(completion))
|
|
335
372
|
process.exit(0);
|
|
336
|
-
buildPatch.branch_strategy = "branch-per-ticket";
|
|
337
373
|
buildPatch.completion = completion;
|
|
338
374
|
buildPatch.provider = recommended.provider;
|
|
339
375
|
buildPatch.pr_ready = completion === "auto-merge";
|
|
@@ -517,6 +553,11 @@ function parseCompletionMode(value) {
|
|
|
517
553
|
return value;
|
|
518
554
|
fail("--completion must be one of: pr, auto-merge, direct-merge, none");
|
|
519
555
|
}
|
|
556
|
+
function parseBranchStrategy(value) {
|
|
557
|
+
if (["current", "batch", "branch-per-ticket"].includes(value))
|
|
558
|
+
return value;
|
|
559
|
+
fail("--branch-strategy must be one of: current, batch, branch-per-ticket");
|
|
560
|
+
}
|
|
520
561
|
function parseProvider(value) {
|
|
521
562
|
if (["auto", "github", "gitlab", "local"].includes(value))
|
|
522
563
|
return value;
|
|
@@ -853,6 +894,7 @@ export function buildTicketsCommand() {
|
|
|
853
894
|
.option("--jira-jql <jql>", "Jira JQL query")
|
|
854
895
|
.option("--url-source <urls...>", "add public HTTP(S) source URLs")
|
|
855
896
|
.option("--agent-preference <agent>", "populate runtime preference (configured | claude | codex)")
|
|
897
|
+
.option("--branch-strategy <strategy>", "build branch strategy default (current | batch | branch-per-ticket)")
|
|
856
898
|
.option("--completion <mode>", "build completion default (pr | auto-merge | direct-merge | none)")
|
|
857
899
|
.option("--provider <provider>", "PR/MR provider default (auto | github | gitlab | local)")
|
|
858
900
|
.option("--auto-merge-wait", "wait for dependency PR/MRs to merge before starting dependent tickets")
|
|
@@ -883,6 +925,7 @@ export function buildTicketsCommand() {
|
|
|
883
925
|
.option("--jira-jql <jql>", "Jira JQL query")
|
|
884
926
|
.option("--url-source <urls...>", "replace saved sources with public HTTP(S) URLs")
|
|
885
927
|
.option("--agent-preference <agent>", "populate runtime preference (configured | claude | codex)")
|
|
928
|
+
.option("--branch-strategy <strategy>", "build branch strategy default (current | batch | branch-per-ticket)")
|
|
886
929
|
.option("--completion <mode>", "build completion default (pr | auto-merge | direct-merge | none)")
|
|
887
930
|
.option("--provider <provider>", "PR/MR provider default (auto | github | gitlab | local)")
|
|
888
931
|
.option("--auto-merge-wait", "wait for dependency PR/MRs to merge before starting dependent tickets")
|
|
@@ -1261,7 +1304,7 @@ export function buildTicketsCommand() {
|
|
|
1261
1304
|
.description("Print the ticket queue to stdout.")
|
|
1262
1305
|
.option("-p, --project <dir>", "project directory (default: cwd)")
|
|
1263
1306
|
.option("--limit <n>", "override view limit")
|
|
1264
|
-
.option("--refresh", "query GitHub/GitLab and refresh cached
|
|
1307
|
+
.option("--refresh", "query GitHub/GitLab and refresh cached batch review state")
|
|
1265
1308
|
.action((opts) => {
|
|
1266
1309
|
try {
|
|
1267
1310
|
const dir = cwd(opts);
|
|
@@ -1275,6 +1318,7 @@ export function buildTicketsCommand() {
|
|
|
1275
1318
|
console.log(line);
|
|
1276
1319
|
return;
|
|
1277
1320
|
}
|
|
1321
|
+
console.log("Batches: none configured. Queue is flat.");
|
|
1278
1322
|
const rows = cmdQueue(dir, opts.limit !== undefined ? Number(opts.limit) : undefined);
|
|
1279
1323
|
if (rows.length === 0) {
|
|
1280
1324
|
console.log("No remaining tickets.");
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ProjectSourceEntry, ProjectSourceLocator, ProjectSourceType, SourceRegistryConfig, SourceSnapshotStorage, TicketSetupSource } from "rafi-spec";
|
|
2
|
+
export declare const SOURCE_REQUEST_START = "RAFI_SOURCE_REQUEST_START";
|
|
3
|
+
export declare const SOURCE_REQUEST_END = "RAFI_SOURCE_REQUEST_END";
|
|
4
|
+
export interface StructuredSourceRequest {
|
|
5
|
+
type?: ProjectSourceType;
|
|
6
|
+
description?: string;
|
|
7
|
+
label?: string;
|
|
8
|
+
locator?: ProjectSourceLocator;
|
|
9
|
+
}
|
|
10
|
+
export interface LoadedSourceRegistry {
|
|
11
|
+
registry: SourceRegistryConfig;
|
|
12
|
+
configured: boolean;
|
|
13
|
+
migrated: boolean;
|
|
14
|
+
warnings: string[];
|
|
15
|
+
}
|
|
16
|
+
export interface CaptureResult {
|
|
17
|
+
registry: SourceRegistryConfig;
|
|
18
|
+
entries: ProjectSourceEntry[];
|
|
19
|
+
snapshots: string[];
|
|
20
|
+
pending: string[];
|
|
21
|
+
}
|
|
22
|
+
export declare function emptySourceRegistry(storage?: SourceSnapshotStorage): SourceRegistryConfig;
|
|
23
|
+
/** Read the shared registry and merge legacy source fields in memory. The next source-aware save persists migration. */
|
|
24
|
+
export declare function loadSourceRegistry(projectDir: string): LoadedSourceRegistry;
|
|
25
|
+
/** Persist the registry while preserving ticket populate/build settings and removing only superseded legacy fields. */
|
|
26
|
+
export declare function saveSourceRegistry(projectDir: string, registry: SourceRegistryConfig): void;
|
|
27
|
+
export declare function addPendingSourceDescription(registry: SourceRegistryConfig, description: string, now?: Date): SourceRegistryConfig;
|
|
28
|
+
export declare function setSourceStorage(registry: SourceRegistryConfig, storage: SourceSnapshotStorage): SourceRegistryConfig;
|
|
29
|
+
export declare function deactivateSource(registry: SourceRegistryConfig, id: string): SourceRegistryConfig;
|
|
30
|
+
/** Remove only capture files introduced by a staged registry; existing history is never touched. */
|
|
31
|
+
export declare function discardStagedSourceCaptures(projectDir: string, original: SourceRegistryConfig, staged: SourceRegistryConfig): void;
|
|
32
|
+
/** Compatibility bridge for legacy ticket setup callers. Existing entries and versions are retained. */
|
|
33
|
+
export declare function appendLegacyTicketSources(registry: SourceRegistryConfig, sources: TicketSetupSource[]): SourceRegistryConfig;
|
|
34
|
+
export declare function extractSourceRequests(output: string): StructuredSourceRequest[];
|
|
35
|
+
/** Deterministically recognize only an entire obvious locator; all other language stays pending for agent interpretation. */
|
|
36
|
+
export declare function sourceRequestFromAnswer(answer: string, projectDir: string): StructuredSourceRequest;
|
|
37
|
+
export declare function registerSourceRequests(projectDir: string, registry: SourceRegistryConfig, requests: StructuredSourceRequest[], opts?: {
|
|
38
|
+
capture?: boolean;
|
|
39
|
+
storage?: SourceSnapshotStorage;
|
|
40
|
+
}): Promise<CaptureResult>;
|
|
41
|
+
export declare function refreshSourceRegistry(projectDir: string, registry: SourceRegistryConfig, ids?: string[]): Promise<CaptureResult>;
|
|
42
|
+
export declare function sourceContext(registry: SourceRegistryConfig, options?: {
|
|
43
|
+
metadataOnly?: Set<string>;
|
|
44
|
+
pinned?: Map<string, Set<string>>;
|
|
45
|
+
}): Array<Record<string, unknown>>;
|
|
46
|
+
export declare function validateSourceVersionRef(registry: SourceRegistryConfig, ref: {
|
|
47
|
+
source_id: string;
|
|
48
|
+
fingerprint: string;
|
|
49
|
+
}, projectDir?: string): string | undefined;
|
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { parse, stringify } from "yaml";
|
|
6
|
+
import { fetchAndSnapshotUrl, normalizePublicHttpUrl, snapshotExternalLocalFile } from "../tickets/sourceFetch.js";
|
|
7
|
+
import { fetchJiraIssues, fetchLinearIssues } from "../tickets/importer.js";
|
|
8
|
+
export const SOURCE_REQUEST_START = "RAFI_SOURCE_REQUEST_START";
|
|
9
|
+
export const SOURCE_REQUEST_END = "RAFI_SOURCE_REQUEST_END";
|
|
10
|
+
const CONFIG_FILE = "rafi-config.yaml";
|
|
11
|
+
const DEFAULT_REGISTRY = { version: 1, snapshot_storage: "local", entries: [] };
|
|
12
|
+
export function emptySourceRegistry(storage = "local") {
|
|
13
|
+
return { ...DEFAULT_REGISTRY, snapshot_storage: storage, entries: [] };
|
|
14
|
+
}
|
|
15
|
+
/** Read the shared registry and merge legacy source fields in memory. The next source-aware save persists migration. */
|
|
16
|
+
export function loadSourceRegistry(projectDir) {
|
|
17
|
+
const path = join(resolve(projectDir), CONFIG_FILE);
|
|
18
|
+
if (!existsSync(path))
|
|
19
|
+
return { registry: emptySourceRegistry(), configured: false, migrated: false, warnings: [] };
|
|
20
|
+
const raw = parse(readFileSync(path, "utf8"));
|
|
21
|
+
const parsed = normalizeRegistry(raw.sources);
|
|
22
|
+
const registry = cloneRegistry(parsed ?? DEFAULT_REGISTRY);
|
|
23
|
+
const warnings = [];
|
|
24
|
+
let migrated = false;
|
|
25
|
+
const planning = asObject(raw.planning);
|
|
26
|
+
if (Array.isArray(planning?.sources)) {
|
|
27
|
+
for (const value of planning.sources) {
|
|
28
|
+
if (typeof value !== "string" || !value.trim())
|
|
29
|
+
continue;
|
|
30
|
+
migrated = true;
|
|
31
|
+
migrateLegacyString(registry, value, warnings);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const tickets = asObject(raw.tickets);
|
|
35
|
+
if (Array.isArray(tickets?.sources)) {
|
|
36
|
+
for (const source of tickets.sources) {
|
|
37
|
+
migrated = true;
|
|
38
|
+
migrateTicketSource(registry, source, warnings);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
warnings.push(...legacyTicketReferenceWarnings(projectDir, registry));
|
|
42
|
+
return { registry, configured: Boolean(parsed), migrated, warnings };
|
|
43
|
+
}
|
|
44
|
+
/** Persist the registry while preserving ticket populate/build settings and removing only superseded legacy fields. */
|
|
45
|
+
export function saveSourceRegistry(projectDir, registry) {
|
|
46
|
+
const root = resolve(projectDir);
|
|
47
|
+
const configPath = join(root, CONFIG_FILE);
|
|
48
|
+
const raw = existsSync(configPath) ? parse(readFileSync(configPath, "utf8")) : {};
|
|
49
|
+
raw.sources = cloneRegistry(registry);
|
|
50
|
+
const planning = asObject(raw.planning);
|
|
51
|
+
if (planning) {
|
|
52
|
+
delete planning.sources;
|
|
53
|
+
if (Object.keys(planning).length === 0)
|
|
54
|
+
delete raw.planning;
|
|
55
|
+
else
|
|
56
|
+
raw.planning = planning;
|
|
57
|
+
}
|
|
58
|
+
const tickets = asObject(raw.tickets);
|
|
59
|
+
if (tickets) {
|
|
60
|
+
delete tickets.sources;
|
|
61
|
+
raw.tickets = tickets;
|
|
62
|
+
}
|
|
63
|
+
mkdirSync(root, { recursive: true });
|
|
64
|
+
writeFileSync(configPath, stringify(raw, { lineWidth: 100 }), "utf8");
|
|
65
|
+
backfillUniqueTicketSourceIds(root, registry);
|
|
66
|
+
ensurePrivateCacheIgnored(root);
|
|
67
|
+
}
|
|
68
|
+
export function addPendingSourceDescription(registry, description, now = new Date()) {
|
|
69
|
+
const value = description.trim();
|
|
70
|
+
if (!value)
|
|
71
|
+
return cloneRegistry(registry);
|
|
72
|
+
const next = cloneRegistry(registry);
|
|
73
|
+
next.pending ??= [];
|
|
74
|
+
if (!next.pending.some((item) => item.description === value))
|
|
75
|
+
next.pending.push({ description: value, created_at: now.toISOString() });
|
|
76
|
+
return next;
|
|
77
|
+
}
|
|
78
|
+
export function setSourceStorage(registry, storage) {
|
|
79
|
+
return { ...cloneRegistry(registry), snapshot_storage: storage };
|
|
80
|
+
}
|
|
81
|
+
export function deactivateSource(registry, id) {
|
|
82
|
+
const next = cloneRegistry(registry);
|
|
83
|
+
const entry = next.entries.find((item) => item.id === id);
|
|
84
|
+
if (!entry)
|
|
85
|
+
throw new Error(`unknown source: ${id}`);
|
|
86
|
+
entry.active = false;
|
|
87
|
+
return next;
|
|
88
|
+
}
|
|
89
|
+
/** Remove only capture files introduced by a staged registry; existing history is never touched. */
|
|
90
|
+
export function discardStagedSourceCaptures(projectDir, original, staged) {
|
|
91
|
+
const root = resolve(projectDir);
|
|
92
|
+
const retained = new Set(original.entries.flatMap((entry) => entry.versions.map((version) => `${entry.id}:${version.fingerprint}`)));
|
|
93
|
+
const originalIds = new Set(original.entries.map((entry) => entry.id));
|
|
94
|
+
for (const entry of staged.entries) {
|
|
95
|
+
for (const version of entry.versions)
|
|
96
|
+
if (!retained.has(`${entry.id}:${version.fingerprint}`)) {
|
|
97
|
+
for (const path of [version.snapshot_path, version.manifest_path]) {
|
|
98
|
+
const absolute = resolve(root, path);
|
|
99
|
+
const rel = relative(root, absolute);
|
|
100
|
+
if (!rel.startsWith("..") && (rel.startsWith(".rafi/source-cache/") || rel.startsWith(".rafi/sources/")))
|
|
101
|
+
rmSync(absolute, { force: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!originalIds.has(entry.id) && entry.type === "local" && entry.locator.path?.startsWith(".tickets/imports/local-"))
|
|
105
|
+
rmSync(resolve(root, entry.locator.path), { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Compatibility bridge for legacy ticket setup callers. Existing entries and versions are retained. */
|
|
109
|
+
export function appendLegacyTicketSources(registry, sources) {
|
|
110
|
+
const next = cloneRegistry(registry);
|
|
111
|
+
const warnings = [];
|
|
112
|
+
for (const source of sources)
|
|
113
|
+
migrateTicketSource(next, source, warnings);
|
|
114
|
+
return next;
|
|
115
|
+
}
|
|
116
|
+
export function extractSourceRequests(output) {
|
|
117
|
+
const requests = [];
|
|
118
|
+
let cursor = 0;
|
|
119
|
+
while (true) {
|
|
120
|
+
const start = output.indexOf(SOURCE_REQUEST_START, cursor);
|
|
121
|
+
if (start < 0)
|
|
122
|
+
break;
|
|
123
|
+
const end = output.indexOf(SOURCE_REQUEST_END, start + SOURCE_REQUEST_START.length);
|
|
124
|
+
if (end < 0)
|
|
125
|
+
throw new Error(`source request is missing ${SOURCE_REQUEST_END}`);
|
|
126
|
+
const body = output.slice(start + SOURCE_REQUEST_START.length, end).trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
|
127
|
+
let value;
|
|
128
|
+
try {
|
|
129
|
+
value = JSON.parse(body);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
throw new Error(`source request is invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
133
|
+
}
|
|
134
|
+
const values = Array.isArray(value) ? value : [value];
|
|
135
|
+
for (const item of values)
|
|
136
|
+
if (item && typeof item === "object" && !Array.isArray(item))
|
|
137
|
+
requests.push(item);
|
|
138
|
+
cursor = end + SOURCE_REQUEST_END.length;
|
|
139
|
+
}
|
|
140
|
+
// Compatibility with the original single-line protocol. Preserve the complete remainder.
|
|
141
|
+
for (const match of output.matchAll(/^SOURCE_REQUEST:\s*(.+)$/gim))
|
|
142
|
+
requests.push({ description: match[1].trim() });
|
|
143
|
+
return requests;
|
|
144
|
+
}
|
|
145
|
+
/** Deterministically recognize only an entire obvious locator; all other language stays pending for agent interpretation. */
|
|
146
|
+
export function sourceRequestFromAnswer(answer, projectDir) {
|
|
147
|
+
const value = answer.trim();
|
|
148
|
+
if (/^https?:\/\/\S+$/i.test(value))
|
|
149
|
+
return requestFromUrl(value);
|
|
150
|
+
const candidate = resolve(projectDir, value);
|
|
151
|
+
if (value && existsSync(candidate))
|
|
152
|
+
return { type: "local", label: basename(candidate), locator: { path: normalizeLocalPath(projectDir, candidate) } };
|
|
153
|
+
if (hasGlobMagic(value) || /^(?:\.{0,2}\/|\/)/.test(value) || /[\\/][^\\/]+\.[A-Za-z0-9]{1,12}$/.test(value))
|
|
154
|
+
return { type: "local", label: basename(value), locator: { path: value } };
|
|
155
|
+
if (/^(?:open\s+)?issues\s+for\s+(?:this|the)\s+repo(?:sitory)?$/i.test(value))
|
|
156
|
+
return { type: inferOriginProvider(projectDir), label: "Open repository issues", locator: { repository: inferOriginRepository(projectDir), mode: "issues", filters: { state: "open" } } };
|
|
157
|
+
return { description: value };
|
|
158
|
+
}
|
|
159
|
+
export async function registerSourceRequests(projectDir, registry, requests, opts = {}) {
|
|
160
|
+
const next = cloneRegistry(registry);
|
|
161
|
+
if (opts.storage)
|
|
162
|
+
next.snapshot_storage = opts.storage;
|
|
163
|
+
const added = [];
|
|
164
|
+
const snapshots = [];
|
|
165
|
+
const pending = [];
|
|
166
|
+
for (const raw of requests) {
|
|
167
|
+
const request = raw.description && !raw.locator ? sourceRequestFromAnswer(raw.description, projectDir) : raw;
|
|
168
|
+
if (!request.type || !request.locator) {
|
|
169
|
+
if (request.description?.trim()) {
|
|
170
|
+
const value = request.description.trim();
|
|
171
|
+
pending.push(value);
|
|
172
|
+
const updated = addPendingSourceDescription(next, value);
|
|
173
|
+
next.pending = updated.pending;
|
|
174
|
+
}
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
let locatorInput = request.locator;
|
|
178
|
+
if (request.type === "local" && locatorInput.path && opts.capture !== false) {
|
|
179
|
+
const absolute = resolve(projectDir, locatorInput.path);
|
|
180
|
+
const rel = relative(resolve(projectDir), absolute);
|
|
181
|
+
if (existsSync(absolute) && (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)))
|
|
182
|
+
locatorInput = { ...locatorInput, path: snapshotExternalLocalFile(projectDir, absolute) };
|
|
183
|
+
}
|
|
184
|
+
const locator = normalizeLocator(request.type, locatorInput, projectDir);
|
|
185
|
+
const key = sourceKey(request.type, locator);
|
|
186
|
+
let entry = next.entries.find((item) => sourceKey(item.type, item.locator) === key);
|
|
187
|
+
if (!entry) {
|
|
188
|
+
entry = { id: stableSourceId(key), type: request.type, label: request.label?.trim() || defaultLabel(request.type, locator), active: true, locator, versions: [] };
|
|
189
|
+
next.entries.push(entry);
|
|
190
|
+
added.push(entry);
|
|
191
|
+
}
|
|
192
|
+
else if (!entry.active)
|
|
193
|
+
entry.active = true;
|
|
194
|
+
if (raw.description?.trim() && next.pending)
|
|
195
|
+
next.pending = next.pending.filter((item) => item.description !== raw.description.trim());
|
|
196
|
+
if (opts.capture !== false) {
|
|
197
|
+
const version = await captureEntry(projectDir, entry, next.snapshot_storage);
|
|
198
|
+
if (!entry.versions.some((item) => item.fingerprint === version.fingerprint))
|
|
199
|
+
entry.versions.push(version);
|
|
200
|
+
snapshots.push(version.snapshot_path);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return { registry: next, entries: added, snapshots: [...new Set(snapshots)], pending };
|
|
204
|
+
}
|
|
205
|
+
export async function refreshSourceRegistry(projectDir, registry, ids) {
|
|
206
|
+
if (ids?.length) {
|
|
207
|
+
const known = new Set(registry.entries.map((entry) => entry.id));
|
|
208
|
+
const missing = ids.filter((id) => !known.has(id));
|
|
209
|
+
if (missing.length)
|
|
210
|
+
throw new Error(`unknown source${missing.length === 1 ? "" : "s"}: ${missing.join(", ")}`);
|
|
211
|
+
}
|
|
212
|
+
const selected = registry.entries.filter((entry) => entry.active && (!ids?.length || ids.includes(entry.id)));
|
|
213
|
+
const next = cloneRegistry(registry);
|
|
214
|
+
const snapshots = [];
|
|
215
|
+
for (const selectedEntry of selected) {
|
|
216
|
+
const entry = next.entries.find((item) => item.id === selectedEntry.id);
|
|
217
|
+
const version = await captureEntry(projectDir, entry, next.snapshot_storage);
|
|
218
|
+
if (!entry.versions.some((item) => item.fingerprint === version.fingerprint))
|
|
219
|
+
entry.versions.push(version);
|
|
220
|
+
snapshots.push(version.snapshot_path);
|
|
221
|
+
}
|
|
222
|
+
return { registry: next, entries: [], snapshots, pending: [] };
|
|
223
|
+
}
|
|
224
|
+
export function sourceContext(registry, options = {}) {
|
|
225
|
+
return registry.entries.map((entry) => {
|
|
226
|
+
const pinned = options.pinned?.get(entry.id);
|
|
227
|
+
const newest = entry.versions.at(-1);
|
|
228
|
+
const versions = entry.versions.filter((version) => pinned?.has(version.fingerprint) || (!options.metadataOnly?.has(entry.id) && version === newest));
|
|
229
|
+
return { id: entry.id, type: entry.type, label: entry.label, active: entry.active, locator: entry.locator, versions };
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
export function validateSourceVersionRef(registry, ref, projectDir = process.cwd()) {
|
|
233
|
+
const source = registry.entries.find((entry) => entry.id === ref.source_id);
|
|
234
|
+
if (!source)
|
|
235
|
+
return `unknown source registry entry ${ref.source_id}`;
|
|
236
|
+
const version = source.versions.find((item) => item.fingerprint === ref.fingerprint);
|
|
237
|
+
if (!version)
|
|
238
|
+
return `unknown captured version ${ref.fingerprint} for ${ref.source_id}`;
|
|
239
|
+
if (!existsSync(resolve(projectDir, version.snapshot_path)))
|
|
240
|
+
return `${version.storage === "local" ? "private" : "tracked"} source snapshot is missing for ${ref.source_id}@${ref.fingerprint}; restore it instead of substituting a newer version`;
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
async function captureEntry(projectDir, entry, storage) {
|
|
244
|
+
if (entry.type === "local")
|
|
245
|
+
return captureLocal(projectDir, entry, storage);
|
|
246
|
+
if (entry.type === "url")
|
|
247
|
+
return captureUrl(projectDir, entry, storage);
|
|
248
|
+
if (entry.type === "github" || entry.type === "gitlab")
|
|
249
|
+
return captureCliProvider(projectDir, entry, storage);
|
|
250
|
+
return captureApiProvider(projectDir, entry, storage);
|
|
251
|
+
}
|
|
252
|
+
function captureLocal(projectDir, entry, storage) {
|
|
253
|
+
const locator = entry.locator.path;
|
|
254
|
+
if (!locator)
|
|
255
|
+
throw new Error(`${entry.id}: local source needs a path`);
|
|
256
|
+
const absolute = isAbsolute(locator) ? locator : resolve(projectDir, locator);
|
|
257
|
+
const files = hasGlobMagic(locator) ? expandLocalGlob(projectDir, locator) : existsSync(absolute) ? collectLocalFiles(absolute) : [];
|
|
258
|
+
if (!files.length)
|
|
259
|
+
throw new Error(`${entry.id}: local source did not match files: ${locator}`);
|
|
260
|
+
const chunks = files.map((file) => {
|
|
261
|
+
const bytes = readFileSync(file);
|
|
262
|
+
return `\n===== ${isAbsolute(locator) ? basename(file) : relative(projectDir, file)} =====\n${bytes.toString("utf8")}`;
|
|
263
|
+
});
|
|
264
|
+
return writeCapture(projectDir, entry, storage, chunks.join("").trimStart(), "text/plain", files.length);
|
|
265
|
+
}
|
|
266
|
+
async function captureUrl(projectDir, entry, storage) {
|
|
267
|
+
if (!entry.locator.url)
|
|
268
|
+
throw new Error(`${entry.id}: URL source needs a URL`);
|
|
269
|
+
const fetched = await fetchAndSnapshotUrl(projectDir, entry.locator.url);
|
|
270
|
+
const version = writeCapture(projectDir, entry, storage, fetched.text, fetched.contentType, 1);
|
|
271
|
+
rmSync(resolve(projectDir, fetched.snapshotPath), { force: true });
|
|
272
|
+
rmSync(resolve(projectDir, fetched.metadataPath), { force: true });
|
|
273
|
+
return version;
|
|
274
|
+
}
|
|
275
|
+
function captureCliProvider(projectDir, entry, storage) {
|
|
276
|
+
const repository = entry.locator.repository || inferOriginRepository(projectDir);
|
|
277
|
+
const mode = entry.locator.mode ?? "issues";
|
|
278
|
+
let command;
|
|
279
|
+
let args;
|
|
280
|
+
if (entry.type === "github") {
|
|
281
|
+
command = "gh";
|
|
282
|
+
if (mode === "issue")
|
|
283
|
+
args = ["issue", "view", String(entry.locator.issue), "--repo", repository, "--json", "number,title,body,state,url,labels,comments"];
|
|
284
|
+
else if (mode === "project")
|
|
285
|
+
args = ["project", "item-list", String(entry.locator.project), "--owner", repository.split("/")[0], "--format", "json", "--limit", "500"];
|
|
286
|
+
else {
|
|
287
|
+
args = ["issue", "list", "--repo", repository, "--state", entry.locator.filters?.state ?? "open", "--limit", entry.locator.filters?.limit ?? "500", "--json", "number,title,body,state,url,labels"];
|
|
288
|
+
appendCliFilter(args, "--search", entry.locator.filters?.q ?? entry.locator.filters?.search);
|
|
289
|
+
appendCliFilter(args, "--label", entry.locator.filters?.label ?? entry.locator.filters?.labels);
|
|
290
|
+
appendCliFilter(args, "--assignee", entry.locator.filters?.assignee);
|
|
291
|
+
appendCliFilter(args, "--author", entry.locator.filters?.author);
|
|
292
|
+
appendCliFilter(args, "--milestone", entry.locator.filters?.milestone);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
command = "glab";
|
|
297
|
+
if (mode === "issue")
|
|
298
|
+
args = ["issue", "view", String(entry.locator.issue), "--repo", repository, "--output", "json"];
|
|
299
|
+
else if (mode === "board") {
|
|
300
|
+
const query = new URLSearchParams({ state: entry.locator.filters?.state ?? "opened", per_page: "100", ...(entry.locator.filters ?? {}) });
|
|
301
|
+
args = ["api", "--paginate", `projects/${encodeURIComponent(repository)}/issues?${query.toString()}`];
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
args = ["issue", "list", "--repo", repository, "--state", entry.locator.filters?.state ?? "opened", "--per-page", entry.locator.filters?.limit ?? "500", "--output", "json"];
|
|
305
|
+
appendCliFilter(args, "--search", entry.locator.filters?.search);
|
|
306
|
+
appendCliFilter(args, "--label", entry.locator.filters?.label ?? entry.locator.filters?.labels);
|
|
307
|
+
appendCliFilter(args, "--assignee", entry.locator.filters?.assignee);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
let text;
|
|
311
|
+
try {
|
|
312
|
+
text = execFileSync(command, args, { cwd: projectDir, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 });
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
throw new Error(`${entry.type} source ${entry.label} could not be retrieved with authenticated ${command}: ${error instanceof Error ? error.message : String(error)}`);
|
|
316
|
+
}
|
|
317
|
+
return writeCapture(projectDir, entry, storage, text, "application/json", countProviderItems(text));
|
|
318
|
+
}
|
|
319
|
+
async function captureApiProvider(projectDir, entry, storage) {
|
|
320
|
+
const opts = { importCap: 500, commentLimit: 10 };
|
|
321
|
+
const items = entry.type === "linear"
|
|
322
|
+
? await fetchLinearIssues({ type: "linear", api_key_env: entry.locator.api_key_env ?? "LINEAR_API_KEY", team_key: entry.locator.team_key, filter: entry.locator.filter }, opts)
|
|
323
|
+
: await fetchJiraIssues({ type: "jira", site: required(entry.locator.site, `${entry.id}: Jira site`), email_env: entry.locator.email_env ?? "JIRA_EMAIL", token_env: entry.locator.token_env ?? "JIRA_API_TOKEN", jql: required(entry.locator.jql, `${entry.id}: Jira JQL`) }, opts);
|
|
324
|
+
return writeCapture(projectDir, entry, storage, `${JSON.stringify(items.map((item) => item.raw), null, 2)}\n`, "application/json", items.length);
|
|
325
|
+
}
|
|
326
|
+
function writeCapture(projectDir, entry, storage, content, contentType, itemCount) {
|
|
327
|
+
const normalized = content.endsWith("\n") ? content : `${content}\n`;
|
|
328
|
+
const fingerprint = createHash("sha256").update(normalized).digest("hex");
|
|
329
|
+
const root = resolve(projectDir);
|
|
330
|
+
const base = join(root, storage === "tracked" ? ".rafi/sources" : ".rafi/source-cache", entry.id);
|
|
331
|
+
const extension = contentType.includes("json") ? "json" : "md";
|
|
332
|
+
const snapshot = join(base, `${fingerprint}.${extension}`);
|
|
333
|
+
const manifest = join(base, `${fingerprint}.manifest.json`);
|
|
334
|
+
mkdirSync(base, { recursive: true });
|
|
335
|
+
if (!existsSync(snapshot))
|
|
336
|
+
writeFileSync(snapshot, normalized, "utf8");
|
|
337
|
+
const version = {
|
|
338
|
+
fingerprint, captured_at: new Date().toISOString(), storage,
|
|
339
|
+
snapshot_path: relative(root, snapshot), manifest_path: relative(root, manifest),
|
|
340
|
+
content_type: contentType, bytes: Buffer.byteLength(normalized), item_count: itemCount,
|
|
341
|
+
};
|
|
342
|
+
if (!existsSync(manifest))
|
|
343
|
+
writeFileSync(manifest, `${JSON.stringify({ source_id: entry.id, type: entry.type, label: entry.label, locator: entry.locator, ...version }, null, 2)}\n`, "utf8");
|
|
344
|
+
return version;
|
|
345
|
+
}
|
|
346
|
+
function collectLocalFiles(path) {
|
|
347
|
+
const stat = statSync(path);
|
|
348
|
+
if (stat.isFile())
|
|
349
|
+
return [path];
|
|
350
|
+
if (!stat.isDirectory())
|
|
351
|
+
throw new Error(`unsupported local source: ${path}`);
|
|
352
|
+
const out = [];
|
|
353
|
+
const visit = (dir) => {
|
|
354
|
+
for (const item of readdirSync(dir, { withFileTypes: true })) {
|
|
355
|
+
if (item.isSymbolicLink() || [".git", "node_modules", ".rafi", ".tickets"].includes(item.name))
|
|
356
|
+
continue;
|
|
357
|
+
const child = join(dir, item.name);
|
|
358
|
+
if (item.isDirectory())
|
|
359
|
+
visit(child);
|
|
360
|
+
else if (item.isFile())
|
|
361
|
+
out.push(child);
|
|
362
|
+
if (out.length > 1000)
|
|
363
|
+
throw new Error("local source directory exceeds 1000 file limit");
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
visit(path);
|
|
367
|
+
return out.sort();
|
|
368
|
+
}
|
|
369
|
+
function hasGlobMagic(value) { return /[*?\[]/.test(value); }
|
|
370
|
+
function expandLocalGlob(projectDir, pattern) {
|
|
371
|
+
if (isAbsolute(pattern))
|
|
372
|
+
throw new Error("absolute globs are not supported; name an external file or directory explicitly");
|
|
373
|
+
const normalized = pattern.split(sep).join("/").replace(/^\.\//, "");
|
|
374
|
+
let body = "";
|
|
375
|
+
for (let index = 0; index < normalized.length; index++) {
|
|
376
|
+
const char = normalized[index];
|
|
377
|
+
if (char === "*" && normalized[index + 1] === "*") {
|
|
378
|
+
body += ".*";
|
|
379
|
+
index++;
|
|
380
|
+
}
|
|
381
|
+
else if (char === "*")
|
|
382
|
+
body += "[^/]*";
|
|
383
|
+
else if (char === "?")
|
|
384
|
+
body += "[^/]";
|
|
385
|
+
else
|
|
386
|
+
body += /[\\^$.[\]{}()+|]/.test(char) ? `\\${char}` : char;
|
|
387
|
+
}
|
|
388
|
+
const expression = new RegExp(`^${body}$`);
|
|
389
|
+
return collectLocalFiles(projectDir).filter((file) => expression.test(relative(projectDir, file).split(sep).join("/")));
|
|
390
|
+
}
|
|
391
|
+
function requestFromUrl(value) {
|
|
392
|
+
const url = new URL(normalizePublicHttpUrl(value));
|
|
393
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
394
|
+
if (url.hostname.toLowerCase() === "github.com" && parts.length >= 2) {
|
|
395
|
+
const issueIndex = parts.indexOf("issues");
|
|
396
|
+
const projectIndex = parts.indexOf("projects");
|
|
397
|
+
if (issueIndex < 0 && projectIndex < 0)
|
|
398
|
+
return { type: "url", label: url.hostname, locator: { url: url.toString() } };
|
|
399
|
+
const projectOwner = ["orgs", "users"].includes(parts[0]) && projectIndex === 2 ? parts[1] : undefined;
|
|
400
|
+
const locator = { repository: projectOwner ?? `${parts[0]}/${parts[1]}`, mode: "issues", filters: Object.fromEntries(url.searchParams) };
|
|
401
|
+
if (issueIndex >= 0 && /^\d+$/.test(parts[issueIndex + 1] ?? "")) {
|
|
402
|
+
locator.mode = "issue";
|
|
403
|
+
locator.issue = Number(parts[issueIndex + 1]);
|
|
404
|
+
}
|
|
405
|
+
else if (projectIndex >= 0) {
|
|
406
|
+
locator.mode = "project";
|
|
407
|
+
locator.project = parts[projectIndex + 1];
|
|
408
|
+
}
|
|
409
|
+
return { type: "github", label: `GitHub ${locator.repository}`, locator };
|
|
410
|
+
}
|
|
411
|
+
if (url.hostname.toLowerCase().includes("gitlab") && parts.length >= 2) {
|
|
412
|
+
const issueIndex = parts.indexOf("issues");
|
|
413
|
+
if (issueIndex < 0 && !parts.includes("boards"))
|
|
414
|
+
return { type: "url", label: url.hostname, locator: { url: url.toString() } };
|
|
415
|
+
const locator = { repository: parts.slice(0, parts.indexOf("-") >= 0 ? parts.indexOf("-") : 2).join("/"), mode: "issues", filters: Object.fromEntries(url.searchParams) };
|
|
416
|
+
if (issueIndex >= 0 && /^\d+$/.test(parts[issueIndex + 1] ?? "")) {
|
|
417
|
+
locator.mode = "issue";
|
|
418
|
+
locator.issue = Number(parts[issueIndex + 1]);
|
|
419
|
+
}
|
|
420
|
+
if (parts.includes("boards"))
|
|
421
|
+
locator.mode = "board";
|
|
422
|
+
return { type: "gitlab", label: `GitLab ${locator.repository}`, locator };
|
|
423
|
+
}
|
|
424
|
+
return { type: "url", label: url.hostname, locator: { url: url.toString() } };
|
|
425
|
+
}
|
|
426
|
+
function normalizeLocator(type, locator, projectDir) {
|
|
427
|
+
const value = { ...locator, filters: locator.filters ? Object.fromEntries(Object.entries(locator.filters).sort()) : undefined };
|
|
428
|
+
if (type === "url" && value.url)
|
|
429
|
+
value.url = normalizePublicHttpUrl(value.url);
|
|
430
|
+
if (type === "local" && value.path)
|
|
431
|
+
value.path = normalizeLocalPath(projectDir, value.path);
|
|
432
|
+
if (value.site)
|
|
433
|
+
value.site = value.site.replace(/\/+$/, "").toLowerCase();
|
|
434
|
+
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
|
435
|
+
}
|
|
436
|
+
function normalizeLocalPath(projectDir, value) {
|
|
437
|
+
const root = resolve(projectDir);
|
|
438
|
+
const absolute = resolve(projectDir, value);
|
|
439
|
+
const rel = relative(root, absolute);
|
|
440
|
+
// Do not persist external absolute locators. The immutable snapshot remains usable.
|
|
441
|
+
return !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel) ? rel || "." : basename(absolute);
|
|
442
|
+
}
|
|
443
|
+
function sourceKey(type, locator) {
|
|
444
|
+
return `${type}:${canonical(locator)}`;
|
|
445
|
+
}
|
|
446
|
+
function stableSourceId(key) { return `src_${createHash("sha256").update(key).digest("hex").slice(0, 16)}`; }
|
|
447
|
+
function canonical(value) {
|
|
448
|
+
if (Array.isArray(value))
|
|
449
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
450
|
+
if (value && typeof value === "object")
|
|
451
|
+
return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(",")}}`;
|
|
452
|
+
return JSON.stringify(value);
|
|
453
|
+
}
|
|
454
|
+
function defaultLabel(type, locator) { return locator.path || locator.url || locator.repository || locator.site || locator.team_key || type; }
|
|
455
|
+
function countProviderItems(text) { try {
|
|
456
|
+
const value = JSON.parse(text);
|
|
457
|
+
return Array.isArray(value) ? value.length : 1;
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
return 0;
|
|
461
|
+
} }
|
|
462
|
+
function appendCliFilter(args, flag, value) { if (value)
|
|
463
|
+
args.push(flag, value); }
|
|
464
|
+
function required(value, label) { if (!value)
|
|
465
|
+
throw new Error(`${label} is required`); return value; }
|
|
466
|
+
function inferOriginProvider(projectDir) {
|
|
467
|
+
const url = originUrl(projectDir).toLowerCase();
|
|
468
|
+
return url.includes("gitlab") ? "gitlab" : "github";
|
|
469
|
+
}
|
|
470
|
+
function inferOriginRepository(projectDir) {
|
|
471
|
+
const value = originUrl(projectDir).replace(/\.git$/, "");
|
|
472
|
+
const match = value.match(/(?:github|gitlab)(?:\.com|\.[^/:]+)[/:](.+)$/i) ?? value.match(/[:/]([^/:]+\/[^/]+)$/);
|
|
473
|
+
if (!match?.[1])
|
|
474
|
+
throw new Error("could not infer repository from git origin; provide owner/repository");
|
|
475
|
+
return match[1];
|
|
476
|
+
}
|
|
477
|
+
function originUrl(projectDir) {
|
|
478
|
+
try {
|
|
479
|
+
return execFileSync("git", ["remote", "get-url", "origin"], { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
throw new Error("could not infer source provider because this project has no readable origin remote");
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
function migrateLegacyString(registry, value, warnings) {
|
|
486
|
+
const trimmed = value.trim();
|
|
487
|
+
if (/^https?:\/\/\S+$/i.test(trimmed)) {
|
|
488
|
+
const req = requestFromUrl(trimmed);
|
|
489
|
+
addLegacyEntry(registry, req.type, req.label, req.locator);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
if (/^[.~\/]|[*?\[]|\.[A-Za-z0-9]{1,8}$/i.test(trimmed)) {
|
|
493
|
+
addLegacyEntry(registry, "local", trimmed, { path: trimmed });
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
registry.pending ??= [];
|
|
497
|
+
registry.pending.push({ description: trimmed, created_at: new Date().toISOString() });
|
|
498
|
+
warnings.push(`legacy source needs interpretation: ${trimmed}`);
|
|
499
|
+
}
|
|
500
|
+
function migrateTicketSource(registry, raw, warnings) {
|
|
501
|
+
if (!raw || typeof raw !== "object")
|
|
502
|
+
return;
|
|
503
|
+
if (raw.type === "local")
|
|
504
|
+
for (const path of raw.paths ?? [])
|
|
505
|
+
addLegacyEntry(registry, "local", path, { path });
|
|
506
|
+
else if (raw.type === "url") {
|
|
507
|
+
const req = requestFromUrl(raw.url);
|
|
508
|
+
addLegacyEntry(registry, req.type, req.label, req.locator);
|
|
509
|
+
}
|
|
510
|
+
else if (raw.type === "linear")
|
|
511
|
+
addLegacyEntry(registry, "linear", raw.team_key ? `Linear ${raw.team_key}` : "Linear", { api_key_env: raw.api_key_env ?? "LINEAR_API_KEY", team_key: raw.team_key, filter: raw.filter });
|
|
512
|
+
else if (raw.type === "jira")
|
|
513
|
+
addLegacyEntry(registry, "jira", `Jira ${raw.site}`, { site: raw.site, email_env: raw.email_env ?? "JIRA_EMAIL", token_env: raw.token_env ?? "JIRA_API_TOKEN", jql: raw.jql });
|
|
514
|
+
else
|
|
515
|
+
warnings.push("unsupported legacy ticket source was left unchanged");
|
|
516
|
+
}
|
|
517
|
+
function addLegacyEntry(registry, type, label, locator) {
|
|
518
|
+
const key = sourceKey(type, locator);
|
|
519
|
+
if (!registry.entries.some((entry) => sourceKey(entry.type, entry.locator) === key))
|
|
520
|
+
registry.entries.push({ id: stableSourceId(key), type, label, active: true, locator, versions: [] });
|
|
521
|
+
}
|
|
522
|
+
function normalizeRegistry(value) {
|
|
523
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
524
|
+
return undefined;
|
|
525
|
+
const raw = value;
|
|
526
|
+
if (raw.version !== 1 || !Array.isArray(raw.entries))
|
|
527
|
+
return undefined;
|
|
528
|
+
return { version: 1, snapshot_storage: raw.snapshot_storage === "tracked" ? "tracked" : "local", entries: raw.entries, ...(Array.isArray(raw.pending) ? { pending: raw.pending } : {}) };
|
|
529
|
+
}
|
|
530
|
+
function cloneRegistry(registry) { return JSON.parse(JSON.stringify(registry)); }
|
|
531
|
+
function asObject(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : undefined; }
|
|
532
|
+
function ensurePrivateCacheIgnored(projectDir) {
|
|
533
|
+
const path = join(projectDir, ".gitignore");
|
|
534
|
+
const marker = ".rafi/source-cache/";
|
|
535
|
+
const current = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
536
|
+
if (!current.split(/\r?\n/).includes(marker))
|
|
537
|
+
writeFileSync(path, `${current}${current && !current.endsWith("\n") ? "\n" : ""}${marker}\n`, "utf8");
|
|
538
|
+
}
|
|
539
|
+
function legacyTicketReferenceWarnings(projectDir, registry) {
|
|
540
|
+
const path = join(projectDir, ".tickets", "tickets.yaml");
|
|
541
|
+
if (!existsSync(path))
|
|
542
|
+
return [];
|
|
543
|
+
const raw = parse(readFileSync(path, "utf8"));
|
|
544
|
+
const warnings = [];
|
|
545
|
+
for (const ticket of raw?.tickets ?? [])
|
|
546
|
+
for (const ref of ticket.source_refs ?? [])
|
|
547
|
+
if (!ref.source_id && ref.source) {
|
|
548
|
+
const matches = matchingSourceIds(registry, ref.source);
|
|
549
|
+
if (matches.length > 1)
|
|
550
|
+
warnings.push(`ticket ${ticket.id ?? "unknown"} has ambiguous legacy source reference ${ref.source}: ${matches.join(", ")}`);
|
|
551
|
+
}
|
|
552
|
+
return warnings;
|
|
553
|
+
}
|
|
554
|
+
function backfillUniqueTicketSourceIds(projectDir, registry) {
|
|
555
|
+
const path = join(projectDir, ".tickets", "tickets.yaml");
|
|
556
|
+
if (!existsSync(path))
|
|
557
|
+
return;
|
|
558
|
+
const raw = parse(readFileSync(path, "utf8"));
|
|
559
|
+
let changed = false;
|
|
560
|
+
for (const ticket of raw?.tickets ?? [])
|
|
561
|
+
for (const ref of ticket.source_refs ?? [])
|
|
562
|
+
if (!ref.source_id && ref.source) {
|
|
563
|
+
const matches = matchingSourceIds(registry, ref.source);
|
|
564
|
+
if (matches.length === 1) {
|
|
565
|
+
ref.source_id = matches[0];
|
|
566
|
+
changed = true;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (changed)
|
|
570
|
+
writeFileSync(path, stringify(raw, { lineWidth: 120 }), "utf8");
|
|
571
|
+
}
|
|
572
|
+
function matchingSourceIds(registry, value) {
|
|
573
|
+
return registry.entries.filter((entry) => {
|
|
574
|
+
const candidates = [entry.id, entry.label, entry.type, entry.locator.url, entry.locator.path, entry.locator.repository, entry.locator.site];
|
|
575
|
+
return candidates.some((candidate) => candidate === value);
|
|
576
|
+
}).map((entry) => entry.id);
|
|
577
|
+
}
|
|
@@ -15,6 +15,13 @@ export interface TicketSliceProposal {
|
|
|
15
15
|
depends_on: string[];
|
|
16
16
|
rollback?: string | null;
|
|
17
17
|
notes?: string | null;
|
|
18
|
+
source_refs?: Array<{
|
|
19
|
+
source_id: string;
|
|
20
|
+
fingerprint: string;
|
|
21
|
+
item?: string;
|
|
22
|
+
url?: string | null;
|
|
23
|
+
note?: string | null;
|
|
24
|
+
}>;
|
|
18
25
|
}
|
|
19
26
|
export interface TicketPopulationProposalV1 {
|
|
20
27
|
version: 1;
|
package/dist/ticketPopulation.js
CHANGED
|
@@ -69,6 +69,10 @@ export function materializeTicketPopulation(proposal, plan, existing) {
|
|
|
69
69
|
depends_on: slice.depends_on.map((ref) => sliceToTicket.get(ref)), summary: slice.summary,
|
|
70
70
|
acceptance: [...slice.acceptance], required_tests: [...slice.required_tests], likely_files: [...slice.likely_files],
|
|
71
71
|
rollback: slice.rollback, notes: slice.notes, plan_ref: { plan_id: plan.plan_id, revision: plan.revision, slice_ref: slice.slice_ref },
|
|
72
|
+
source_refs: (plan.slices.find((item) => item.slice_ref === slice.slice_ref)?.source_refs ?? slice.source_refs)?.map((ref) => ({
|
|
73
|
+
source: ref.source_id, item: ref.item ?? "document", source_id: ref.source_id, fingerprint: ref.fingerprint,
|
|
74
|
+
...(ref.url !== undefined ? { url: ref.url } : {}), ...(ref.note !== undefined ? { note: ref.note } : {}),
|
|
75
|
+
})),
|
|
72
76
|
};
|
|
73
77
|
});
|
|
74
78
|
const ownedIds = new Set(materialized.map((ticket) => ticket.id));
|
package/dist/tickets/commands.js
CHANGED
|
@@ -134,6 +134,7 @@ export function cmdInit(projectDir, opts) {
|
|
|
134
134
|
properties: {
|
|
135
135
|
source: { type: "string", minLength: 1 },
|
|
136
136
|
item: { type: "string", minLength: 1 },
|
|
137
|
+
source_id: { type: "string", pattern: "^src_[A-Za-z0-9_-]+$" },
|
|
137
138
|
url: { type: ["string", "null"] },
|
|
138
139
|
fingerprint: { type: ["string", "null"] },
|
|
139
140
|
note: { type: ["string", "null"] },
|
package/dist/tickets/queue.d.ts
CHANGED
|
@@ -35,5 +35,5 @@ export interface ActiveStatusRow {
|
|
|
35
35
|
}
|
|
36
36
|
export declare function buildNextQueue(ticketDefs: TicketDef[], states: Map<string, TicketState>, queueLimit: number): QueueRow[];
|
|
37
37
|
export declare function buildActiveStatusRows(queueRows: QueueRow[], ticketDefs: TicketDef[], states: Map<string, TicketState>): ActiveStatusRow[];
|
|
38
|
-
/** Human-readable queue blocks keep a whole unfinished
|
|
38
|
+
/** Human-readable queue blocks keep a whole unfinished batch and its resume point visible. */
|
|
39
39
|
export declare function formatStackAwareQueue(ticketDefs: TicketDef[], states: Map<string, TicketState>, delivery?: DeliveryConfig): string[];
|
package/dist/tickets/queue.js
CHANGED
|
@@ -61,15 +61,16 @@ export function buildActiveStatusRows(queueRows, ticketDefs, states) {
|
|
|
61
61
|
};
|
|
62
62
|
});
|
|
63
63
|
}
|
|
64
|
-
/** Human-readable queue blocks keep a whole unfinished
|
|
64
|
+
/** Human-readable queue blocks keep a whole unfinished batch and its resume point visible. */
|
|
65
65
|
export function formatStackAwareQueue(ticketDefs, states, delivery) {
|
|
66
66
|
if (!delivery?.stacks?.length)
|
|
67
|
-
return buildNextQueue(ticketDefs, states, Number.MAX_SAFE_INTEGER).map((row) => `${row.ticket} ${row.status} ${row.title}`);
|
|
67
|
+
return ["Batches: none configured. Queue is flat.", ...buildNextQueue(ticketDefs, states, Number.MAX_SAFE_INTEGER).map((row) => `${row.ticket} ${row.status} ${row.title}`)];
|
|
68
68
|
const defs = new Map(ticketDefs.map((ticket) => [ticket.id, ticket]));
|
|
69
69
|
const unitById = new Map(delivery.units.map((unit) => [unit.id, unit]));
|
|
70
70
|
const stackedTickets = new Set();
|
|
71
71
|
const lines = [];
|
|
72
|
-
|
|
72
|
+
lines.push(`Batches: ${delivery.stacks.length} configured.`);
|
|
73
|
+
for (const [index, stack] of delivery.stacks.entries()) {
|
|
73
74
|
const nodes = normalizeStackNodes(stack, delivery.units, ticketDefs);
|
|
74
75
|
const ticketIds = stack.units.flatMap((id) => unitById.get(id)?.tickets ?? []);
|
|
75
76
|
ticketIds.forEach((id) => stackedTickets.add(id));
|
|
@@ -79,16 +80,26 @@ export function formatStackAwareQueue(ticketDefs, states, delivery) {
|
|
|
79
80
|
const next = ticketIds.find((id) => !terminal(states.get(id)?.status));
|
|
80
81
|
const blockers = next && defs.get(next) ? resolveBlockers(defs.get(next), states) : [];
|
|
81
82
|
const state = deriveStackState(stack, ticketIds, states, blockers);
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
const units = stack.units.map((id) => unitById.get(id)).filter((unit) => Boolean(unit));
|
|
84
|
+
const branchModes = [...new Set(units.map((unit) => unit.branch_mode))].join(",") || "unknown";
|
|
85
|
+
const prChain = units.some((unit) => unit.dependency_mode === "stack") || nodes.length > 1;
|
|
86
|
+
lines.push(`=== Batch ${index + 1}: ${stack.name} (${stack.id}) ===`);
|
|
87
|
+
lines.push(`status=${state}; tickets=${ticketIds.length}; branch_mode=${branchModes}; blockers=${blockers.join(",") || "none"}; completed=${completed.join(",") || "none"}; next=${next ?? "none"}`);
|
|
88
|
+
if (prChain)
|
|
89
|
+
lines.push(`delivery=PR chain (${nodes.length} dependent branch${nodes.length === 1 ? "" : "es"})`);
|
|
84
90
|
lines.push(`reviews=${stack.review_links?.join(",") || "none"}; remote_checked=${stack.remote_checked_at ?? "never"}${stack.remote_stale ? " (stale; run --refresh for a live check)" : ""}`);
|
|
85
91
|
for (const id of ticketIds)
|
|
86
92
|
lines.push(`${id} ${states.get(id)?.status ?? "planned"} ${defs.get(id)?.title ?? "unknown ticket"}`);
|
|
87
|
-
lines.push(`===
|
|
93
|
+
lines.push(`=== End Batch ${index + 1}: ${stack.name} (${stack.id}) ===`);
|
|
88
94
|
}
|
|
95
|
+
let unbatched = false;
|
|
89
96
|
for (const ticket of ticketDefs.sort((a, b) => a.order - b.order)) {
|
|
90
97
|
if (stackedTickets.has(ticket.id) || terminal(states.get(ticket.id)?.status))
|
|
91
98
|
continue;
|
|
99
|
+
if (!unbatched) {
|
|
100
|
+
lines.push("Unbatched tickets:");
|
|
101
|
+
unbatched = true;
|
|
102
|
+
}
|
|
92
103
|
const blockers = resolveBlockers(ticket, states);
|
|
93
104
|
lines.push(`${ticket.id} ${computeDisplayStatus(states.get(ticket.id)?.status ?? "planned", blockers)} ${ticket.title}`);
|
|
94
105
|
}
|
|
@@ -3,7 +3,7 @@ export type HarnessTarget = "claude" | "codex";
|
|
|
3
3
|
export type TicketBuildCompletionMode = "pr" | "auto-merge" | "direct-merge" | "none";
|
|
4
4
|
export type TicketBuildProvider = "auto" | "github" | "gitlab" | "local";
|
|
5
5
|
export type TicketBuildMergeMethod = "squash" | "merge" | "rebase";
|
|
6
|
-
export type TicketBuildBranchStrategy = "
|
|
6
|
+
export type TicketBuildBranchStrategy = "current" | "batch" | "branch-per-ticket";
|
|
7
7
|
export type TicketPopulateAgentPreference = "configured" | "claude" | "codex";
|
|
8
8
|
export type TicketPopulateEnrichmentPolicy = "none" | "recommendations" | "agent";
|
|
9
9
|
export type TicketSourceConfig = {
|
|
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { parse, stringify } from "yaml";
|
|
5
5
|
import { loadDefaults } from "special-agents";
|
|
6
|
+
import { appendLegacyTicketSources, loadSourceRegistry } from "../sources/sourceRegistry.js";
|
|
6
7
|
export const RAFI_CONFIG_FILE = "rafi-config.yaml";
|
|
7
8
|
const AGENT_NAMES = ["builder", "qa", "planner", "ticket-maker", "uninstaller"];
|
|
8
9
|
const SKILL_NAMES = [
|
|
@@ -48,7 +49,10 @@ export function loadTicketSetupConfig(projectDir) {
|
|
|
48
49
|
const raw = loadRafiConfigObject(projectDir);
|
|
49
50
|
if (!raw || raw.tickets === undefined)
|
|
50
51
|
return undefined;
|
|
51
|
-
|
|
52
|
+
const setup = normalizeTicketsSetupConfig(raw.tickets, `${RAFI_CONFIG_FILE}.tickets`);
|
|
53
|
+
if (setup.sources.length === 0)
|
|
54
|
+
setup.sources = legacySourcesFromRegistry(loadSourceRegistry(projectDir).registry.entries);
|
|
55
|
+
return setup;
|
|
52
56
|
}
|
|
53
57
|
export function loadTicketSetupConfigWithDefaults(projectDir) {
|
|
54
58
|
return loadTicketSetupConfig(projectDir) ?? cloneTicketSetup(DEFAULT_TICKET_SETUP);
|
|
@@ -59,7 +63,17 @@ export function hasTicketSetupConfig(projectDir) {
|
|
|
59
63
|
}
|
|
60
64
|
export function saveTicketSetupConfig(projectDir, setup, opts = {}) {
|
|
61
65
|
const config = loadRafiConfigObject(projectDir) ?? minimalRafiConfig(projectDir, opts);
|
|
62
|
-
|
|
66
|
+
const normalized = normalizeTicketsSetupConfig(setup, "tickets");
|
|
67
|
+
const ticketConfig = denormalizeTicketsSetupConfig(normalized);
|
|
68
|
+
delete ticketConfig.sources;
|
|
69
|
+
config.tickets = ticketConfig;
|
|
70
|
+
config.sources = appendLegacyTicketSources(loadSourceRegistry(projectDir).registry, normalized.sources);
|
|
71
|
+
const planning = config.planning && typeof config.planning === "object" && !Array.isArray(config.planning) ? config.planning : undefined;
|
|
72
|
+
if (planning) {
|
|
73
|
+
delete planning.sources;
|
|
74
|
+
if (Object.keys(planning).length === 0)
|
|
75
|
+
delete config.planning;
|
|
76
|
+
}
|
|
63
77
|
mkdirSync(projectDir, { recursive: true });
|
|
64
78
|
writeFileSync(join(projectDir, RAFI_CONFIG_FILE), stringify(config, { lineWidth: 100 }), "utf8");
|
|
65
79
|
}
|
|
@@ -332,7 +346,7 @@ function normalizePopulate(value, label) {
|
|
|
332
346
|
function normalizeBuild(value, label) {
|
|
333
347
|
const raw = objectOrEmpty(value, label);
|
|
334
348
|
return {
|
|
335
|
-
branch_strategy: enumField(raw.branch_strategy, ["branch-per-ticket"
|
|
349
|
+
branch_strategy: enumField(raw.branch_strategy, ["current", "batch", "branch-per-ticket"], DEFAULT_TICKET_SETUP.build.branch_strategy, `${label}.branch_strategy`),
|
|
336
350
|
completion: enumField(raw.completion, ["pr", "auto-merge", "direct-merge", "none"], DEFAULT_TICKET_SETUP.build.completion, `${label}.completion`),
|
|
337
351
|
provider: enumField(raw.provider, ["auto", "github", "gitlab", "local"], DEFAULT_TICKET_SETUP.build.provider, `${label}.provider`),
|
|
338
352
|
pr_ready: booleanField(raw.pr_ready, DEFAULT_TICKET_SETUP.build.pr_ready, `${label}.pr_ready`),
|
|
@@ -424,3 +438,17 @@ function cloneTicketSetup(setup) {
|
|
|
424
438
|
function unique(values) {
|
|
425
439
|
return [...new Set(values)];
|
|
426
440
|
}
|
|
441
|
+
function legacySourcesFromRegistry(entries) {
|
|
442
|
+
const out = [];
|
|
443
|
+
for (const entry of entries.filter((item) => item.active)) {
|
|
444
|
+
if (entry.type === "local" && entry.locator.path)
|
|
445
|
+
out.push({ type: "local", paths: [entry.locator.path] });
|
|
446
|
+
else if (entry.type === "url" && entry.locator.url)
|
|
447
|
+
out.push({ type: "url", url: entry.locator.url });
|
|
448
|
+
else if (entry.type === "linear")
|
|
449
|
+
out.push({ type: "linear", api_key_env: entry.locator.api_key_env ?? "LINEAR_API_KEY", team_key: entry.locator.team_key, filter: entry.locator.filter });
|
|
450
|
+
else if (entry.type === "jira" && entry.locator.site && entry.locator.jql)
|
|
451
|
+
out.push({ type: "jira", site: entry.locator.site, email_env: entry.locator.email_env ?? "JIRA_EMAIL", token_env: entry.locator.token_env ?? "JIRA_API_TOKEN", jql: entry.locator.jql });
|
|
452
|
+
}
|
|
453
|
+
return dedupeTicketSources(out);
|
|
454
|
+
}
|
|
@@ -23,7 +23,7 @@ export async function fetchAndSnapshotUrl(projectDir, input, limits = {}) {
|
|
|
23
23
|
response = await fetch(current, {
|
|
24
24
|
redirect: "manual",
|
|
25
25
|
signal: controller.signal,
|
|
26
|
-
headers: { Accept: "text/html,text/plain,text/markdown,application/pdf;q=0.9" },
|
|
26
|
+
headers: { Accept: "text/html,text/plain,text/markdown,application/json,application/yaml,text/yaml,application/pdf;q=0.9" },
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
29
|
finally {
|
|
@@ -154,6 +154,11 @@ function detectContentType(header, bytes, url) {
|
|
|
154
154
|
return "html";
|
|
155
155
|
if (["text/markdown", "text/x-markdown"].includes(mime) || /\.md(?:own)?$/i.test(new URL(url).pathname))
|
|
156
156
|
return "markdown";
|
|
157
|
+
if (["application/json", "application/yaml", "application/x-yaml", "text/yaml", "text/x-yaml"].includes(mime) || /\.(?:json|ya?ml)$/i.test(new URL(url).pathname)) {
|
|
158
|
+
if (bytes.subarray(0, Math.min(bytes.length, 1024)).includes(0))
|
|
159
|
+
throw new Error("binary URL content is not supported");
|
|
160
|
+
return "text";
|
|
161
|
+
}
|
|
157
162
|
if (!mime || mime.startsWith("text/") || mime === "application/octet-stream") {
|
|
158
163
|
if (bytes.subarray(0, Math.min(bytes.length, 1024)).includes(0))
|
|
159
164
|
throw new Error("binary URL content is not supported");
|
|
@@ -11,6 +11,7 @@ export interface TicketExternalRef {
|
|
|
11
11
|
export interface TicketSourceRef {
|
|
12
12
|
source: string;
|
|
13
13
|
item: string;
|
|
14
|
+
source_id?: string;
|
|
14
15
|
url?: string | null;
|
|
15
16
|
fingerprint?: string | null;
|
|
16
17
|
note?: string | null;
|
|
@@ -149,6 +150,10 @@ export declare const TICKET_JSON_SCHEMA: {
|
|
|
149
150
|
readonly type: "string";
|
|
150
151
|
readonly minLength: 1;
|
|
151
152
|
};
|
|
153
|
+
readonly source_id: {
|
|
154
|
+
readonly type: "string";
|
|
155
|
+
readonly pattern: "^src_[A-Za-z0-9_-]+$";
|
|
156
|
+
};
|
|
152
157
|
readonly url: {
|
|
153
158
|
readonly type: readonly ["string", "null"];
|
|
154
159
|
};
|
|
@@ -310,6 +315,10 @@ export declare const TICKETS_FILE_SCHEMA: {
|
|
|
310
315
|
readonly type: "string";
|
|
311
316
|
readonly minLength: 1;
|
|
312
317
|
};
|
|
318
|
+
readonly source_id: {
|
|
319
|
+
readonly type: "string";
|
|
320
|
+
readonly pattern: "^src_[A-Za-z0-9_-]+$";
|
|
321
|
+
};
|
|
313
322
|
readonly url: {
|
|
314
323
|
readonly type: readonly ["string", "null"];
|
|
315
324
|
};
|
|
@@ -43,6 +43,7 @@ export const TICKET_JSON_SCHEMA = {
|
|
|
43
43
|
properties: {
|
|
44
44
|
source: { type: "string", minLength: 1 },
|
|
45
45
|
item: { type: "string", minLength: 1 },
|
|
46
|
+
source_id: { type: "string", pattern: "^src_[A-Za-z0-9_-]+$" },
|
|
46
47
|
url: { type: ["string", "null"] },
|
|
47
48
|
fingerprint: { type: ["string", "null"] },
|
|
48
49
|
note: { type: ["string", "null"] },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-foreman",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.2",
|
|
4
4
|
"description": "An orchestrator that keeps Codex CLI / Claude Code builders moving through their step list unattended. Part of Rafi.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -65,6 +65,10 @@
|
|
|
65
65
|
"types": "./dist/tickets/setupConfig.d.ts",
|
|
66
66
|
"default": "./dist/tickets/setupConfig.js"
|
|
67
67
|
},
|
|
68
|
+
"./sources/source-registry.js": {
|
|
69
|
+
"types": "./dist/sources/sourceRegistry.d.ts",
|
|
70
|
+
"default": "./dist/sources/sourceRegistry.js"
|
|
71
|
+
},
|
|
68
72
|
"./cli/tickets.js": {
|
|
69
73
|
"types": "./dist/cli/tickets.d.ts",
|
|
70
74
|
"default": "./dist/cli/tickets.js"
|
|
@@ -114,8 +118,8 @@
|
|
|
114
118
|
"@anthropic-ai/claude-agent-sdk": "^0.3.148"
|
|
115
119
|
},
|
|
116
120
|
"dependencies": {
|
|
117
|
-
"special-agents": "0.8.
|
|
118
|
-
"rafi-spec": "0.8.
|
|
121
|
+
"special-agents": "0.8.2",
|
|
122
|
+
"rafi-spec": "0.8.2",
|
|
119
123
|
"@clack/prompts": "^1.4.0",
|
|
120
124
|
"ajv": "^8.20.0",
|
|
121
125
|
"ajv-formats": "^3.0.1",
|