edsger 0.67.0 → 0.69.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/dist/api/github.d.ts +23 -0
- package/dist/api/github.js +61 -0
- package/dist/commands/data-flow/index.d.ts +3 -1
- package/dist/commands/data-flow/index.js +14 -5
- package/dist/commands/quality-benchmark/index.js +43 -9
- package/dist/commands/recipes/index.d.ts +3 -1
- package/dist/commands/recipes/index.js +10 -4
- package/dist/commands/screen-flow/index.d.ts +3 -1
- package/dist/commands/screen-flow/index.js +14 -5
- package/dist/commands/sync-org-repos/index.d.ts +3 -2
- package/dist/commands/sync-org-repos/index.js +3 -2
- package/dist/index.js +29 -11
- package/dist/phases/data-flow/index.d.ts +4 -1
- package/dist/phases/data-flow/index.js +24 -3
- package/dist/phases/flow-shared/clone-repos.d.ts +8 -2
- package/dist/phases/flow-shared/clone-repos.js +36 -18
- package/dist/phases/recipes/index.d.ts +8 -1
- package/dist/phases/recipes/index.js +74 -17
- package/dist/phases/recipes/mcp-server.d.ts +4 -1
- package/dist/phases/recipes/mcp-server.js +43 -18
- package/dist/phases/screen-flow/index.d.ts +4 -1
- package/dist/phases/screen-flow/index.js +24 -3
- package/package.json +1 -1
package/dist/api/github.d.ts
CHANGED
|
@@ -50,6 +50,29 @@ export declare function getGitHubConfigByProduct(productId: string, verbose?: bo
|
|
|
50
50
|
repo?: string;
|
|
51
51
|
message?: string;
|
|
52
52
|
}>;
|
|
53
|
+
export interface RepositoryBasics {
|
|
54
|
+
teamId: string | null;
|
|
55
|
+
fullName: string | null;
|
|
56
|
+
description: string | null;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Fetch a repository row's team_id, full_name and description (RLS-scoped).
|
|
60
|
+
* Used by repo-scoped analysis to resolve the team (recipes are team-scoped)
|
|
61
|
+
* and to build prompt "product basics" from the repo instead of a product.
|
|
62
|
+
*/
|
|
63
|
+
export declare function getRepositoryBasics(repositoryId: string): Promise<RepositoryBasics>;
|
|
64
|
+
/**
|
|
65
|
+
* Get GitHub config and token by repository ID (no product / issue required).
|
|
66
|
+
* Used for repo-scoped analysis (quality-benchmark / recipes / flows running
|
|
67
|
+
* against a single `repositories` row with no product context).
|
|
68
|
+
*/
|
|
69
|
+
export declare function getGitHubConfigByRepository(repositoryId: string, verbose?: boolean): Promise<{
|
|
70
|
+
configured: boolean;
|
|
71
|
+
token?: string;
|
|
72
|
+
owner?: string;
|
|
73
|
+
repo?: string;
|
|
74
|
+
message?: string;
|
|
75
|
+
}>;
|
|
53
76
|
/**
|
|
54
77
|
* Get GitHub config and token in one call
|
|
55
78
|
* This is the main entry point for getting GitHub config
|
package/dist/api/github.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* GitHub API client for accessing GitHub via MCP server
|
|
3
3
|
* Uses product developer configuration from the database
|
|
4
4
|
*/
|
|
5
|
+
import { getSupabase } from '../supabase/client.js';
|
|
5
6
|
import { logDebug, logError, logSuccess, logWarning } from '../utils/logger.js';
|
|
6
7
|
import { callMcpEndpoint } from './mcp-client.js';
|
|
7
8
|
/**
|
|
@@ -98,6 +99,66 @@ export async function getGitHubConfigByProduct(productId, verbose) {
|
|
|
98
99
|
};
|
|
99
100
|
}
|
|
100
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Fetch a repository row's team_id, full_name and description (RLS-scoped).
|
|
104
|
+
* Used by repo-scoped analysis to resolve the team (recipes are team-scoped)
|
|
105
|
+
* and to build prompt "product basics" from the repo instead of a product.
|
|
106
|
+
*/
|
|
107
|
+
export async function getRepositoryBasics(repositoryId) {
|
|
108
|
+
const { data } = await getSupabase()
|
|
109
|
+
.from('repositories')
|
|
110
|
+
.select('team_id, full_name, description')
|
|
111
|
+
.eq('id', repositoryId)
|
|
112
|
+
.maybeSingle();
|
|
113
|
+
return {
|
|
114
|
+
teamId: data?.team_id ?? null,
|
|
115
|
+
fullName: data?.full_name ?? null,
|
|
116
|
+
description: data?.description ?? null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Get GitHub config and token by repository ID (no product / issue required).
|
|
121
|
+
* Used for repo-scoped analysis (quality-benchmark / recipes / flows running
|
|
122
|
+
* against a single `repositories` row with no product context).
|
|
123
|
+
*/
|
|
124
|
+
export async function getGitHubConfigByRepository(repositoryId, verbose) {
|
|
125
|
+
logDebug(`Fetching GitHub config for repository: ${repositoryId}`, verbose);
|
|
126
|
+
try {
|
|
127
|
+
const result = (await callMcpEndpoint('github/config_and_token_by_repository', {
|
|
128
|
+
repository_id: repositoryId,
|
|
129
|
+
}));
|
|
130
|
+
if (verbose) {
|
|
131
|
+
if (result.configured) {
|
|
132
|
+
logSuccess(`GitHub ready: ${result.repository_full_name}`);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
logWarning(`GitHub not configured: ${result.message}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (result.configured && result.token && result.owner && result.repo) {
|
|
139
|
+
return {
|
|
140
|
+
configured: true,
|
|
141
|
+
token: result.token,
|
|
142
|
+
owner: result.owner,
|
|
143
|
+
repo: result.repo,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
configured: false,
|
|
148
|
+
message: result.message ||
|
|
149
|
+
'GitHub not configured for this repository. Connect the repo first.',
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (verbose) {
|
|
154
|
+
logError(`Failed to get GitHub config for repository: ${error instanceof Error ? error.message : String(error)}`);
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
configured: false,
|
|
158
|
+
message: error instanceof Error ? error.message : 'Failed to get GitHub config',
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
101
162
|
/**
|
|
102
163
|
* Get GitHub config and token in one call
|
|
103
164
|
* This is the main entry point for getting GitHub config
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
*/
|
|
12
12
|
export interface DataFlowCliOptions {
|
|
13
13
|
flowId: string;
|
|
14
|
+
/** Repo-only mode: generate against a single repositories row, no product. */
|
|
15
|
+
repoId?: string;
|
|
14
16
|
guidance?: string;
|
|
15
17
|
verbose?: boolean;
|
|
16
18
|
}
|
|
17
|
-
export declare function runDataFlow(productId: string, options: DataFlowCliOptions): Promise<void>;
|
|
19
|
+
export declare function runDataFlow(productId: string | undefined, options: DataFlowCliOptions): Promise<void>;
|
|
@@ -13,18 +13,27 @@ import { runDataFlowPhase } from '../../phases/data-flow/index.js';
|
|
|
13
13
|
import { deregisterSession, registerSession, } from '../../system/session-manager.js';
|
|
14
14
|
import { logError, logInfo, logSuccess } from '../../utils/logger.js';
|
|
15
15
|
export async function runDataFlow(productId, options) {
|
|
16
|
-
const { flowId, guidance, verbose } = options;
|
|
17
|
-
if (!productId) {
|
|
18
|
-
throw new Error('
|
|
16
|
+
const { flowId, repoId, guidance, verbose } = options;
|
|
17
|
+
if (!productId && !repoId) {
|
|
18
|
+
throw new Error('Either a product ID or --repo-id is required for data-flow');
|
|
19
19
|
}
|
|
20
20
|
if (!flowId) {
|
|
21
21
|
throw new Error('--flow-id is required (the pending flows row id)');
|
|
22
22
|
}
|
|
23
|
-
await registerSession({
|
|
24
|
-
|
|
23
|
+
await registerSession({
|
|
24
|
+
command: 'data-flow',
|
|
25
|
+
...(productId ? { productId } : {}),
|
|
26
|
+
});
|
|
27
|
+
if (productId) {
|
|
28
|
+
logInfo(`Starting data flow generation for product ${productId}`);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
logInfo(`Starting data flow generation for repository ${repoId}`);
|
|
32
|
+
}
|
|
25
33
|
try {
|
|
26
34
|
const result = await runDataFlowPhase({
|
|
27
35
|
productId,
|
|
36
|
+
repoId,
|
|
28
37
|
flowId,
|
|
29
38
|
guidance,
|
|
30
39
|
verbose,
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
27
27
|
import { dirname, resolve } from 'node:path';
|
|
28
|
-
import { getGitHubConfigByProduct } from '../../api/github.js';
|
|
28
|
+
import { getGitHubConfigByProduct, getGitHubConfigByRepository, getRepositoryBasics, } from '../../api/github.js';
|
|
29
29
|
import { callMcpEndpoint } from '../../api/mcp-client.js';
|
|
30
30
|
import { fetchProductBasics } from '../../phases/find-shared/mcp.js';
|
|
31
31
|
import { runQualityBenchmark, } from '../../phases/quality-benchmark/index.js';
|
|
@@ -34,15 +34,46 @@ import { getSupabase } from '../../supabase/client.js';
|
|
|
34
34
|
import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
|
|
35
35
|
export async function runQualityBenchmarkCli(productId, options) {
|
|
36
36
|
const installEnabled = options.install !== false;
|
|
37
|
-
|
|
37
|
+
// Repo-only mode: no product context, benchmark a single repositories row.
|
|
38
|
+
const repoOnly = !productId && Boolean(options.repoId);
|
|
39
|
+
if (repoOnly) {
|
|
40
|
+
logInfo(`Starting quality benchmark for repository ${options.repoId}`);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
logInfo(`Starting quality benchmark for product ${productId}`);
|
|
44
|
+
}
|
|
38
45
|
let repoRoot;
|
|
39
46
|
let resolvedBranch;
|
|
40
47
|
// Recorded on the report so it's scoped to the repo that was benchmarked.
|
|
41
48
|
let repositoryId = options.repoId ?? null;
|
|
49
|
+
// Display name used on the prompt + report (repo full_name in repo-only mode).
|
|
50
|
+
let productName = productId;
|
|
42
51
|
if (options.repo) {
|
|
43
52
|
repoRoot = resolve(options.repo);
|
|
44
53
|
resolvedBranch = options.branch;
|
|
45
54
|
logInfo(`Repo (override): ${repoRoot}`);
|
|
55
|
+
if (repoOnly) {
|
|
56
|
+
const basics = await getRepositoryBasics(options.repoId).catch(() => null);
|
|
57
|
+
productName = basics?.fullName ?? options.repoId;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else if (repoOnly) {
|
|
61
|
+
const gh = await getGitHubConfigByRepository(options.repoId, options.verbose);
|
|
62
|
+
if (!gh.configured || !gh.token || !gh.owner || !gh.repo) {
|
|
63
|
+
logError(`Cannot run quality benchmark: ${gh.message ??
|
|
64
|
+
'GitHub is not configured for this repository. Connect the repo first.'}`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
const ws = prepareQualityWorkspace({
|
|
68
|
+
owner: gh.owner,
|
|
69
|
+
repo: gh.repo,
|
|
70
|
+
token: gh.token,
|
|
71
|
+
verbose: options.verbose,
|
|
72
|
+
});
|
|
73
|
+
repoRoot = ws.repoPath;
|
|
74
|
+
resolvedBranch = options.branch ?? ws.branch;
|
|
75
|
+
productName = `${gh.owner}/${gh.repo}`;
|
|
76
|
+
logInfo(`Repo: ${repoRoot} (branch: ${resolvedBranch})`);
|
|
46
77
|
}
|
|
47
78
|
else {
|
|
48
79
|
const gh = await getGitHubConfigByProduct(productId, options.verbose);
|
|
@@ -82,10 +113,12 @@ export async function runQualityBenchmarkCli(productId, options) {
|
|
|
82
113
|
if (!installEnabled) {
|
|
83
114
|
logWarning('Install consent NOT granted (--no-install). Missing tools will be marked unmeasured.');
|
|
84
115
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
116
|
+
if (!repoOnly) {
|
|
117
|
+
const basics = await fetchProductBasics(productId).catch(() => ({
|
|
118
|
+
name: productId,
|
|
119
|
+
}));
|
|
120
|
+
productName = basics.name;
|
|
121
|
+
}
|
|
89
122
|
const onProgress = (event) => {
|
|
90
123
|
if (options.verbose) {
|
|
91
124
|
logInfo(`[${event.phase}] ${event.message}`);
|
|
@@ -100,7 +133,7 @@ export async function runQualityBenchmarkCli(productId, options) {
|
|
|
100
133
|
}
|
|
101
134
|
};
|
|
102
135
|
const outcome = await runQualityBenchmark({
|
|
103
|
-
productId,
|
|
136
|
+
productId: repoOnly ? '' : productId,
|
|
104
137
|
productName,
|
|
105
138
|
repoRoot,
|
|
106
139
|
branch: resolvedBranch,
|
|
@@ -121,7 +154,8 @@ export async function runQualityBenchmarkCli(productId, options) {
|
|
|
121
154
|
resolve(process.cwd(), `quality-report-${outcome.commitSha.slice(0, 8)}.json`);
|
|
122
155
|
const reportEnvelope = {
|
|
123
156
|
run_id: outcome.runId,
|
|
124
|
-
product_id: productId,
|
|
157
|
+
product_id: repoOnly ? null : productId,
|
|
158
|
+
repository_id: repositoryId,
|
|
125
159
|
commit_sha: outcome.commitSha,
|
|
126
160
|
branch: resolvedBranch ?? null,
|
|
127
161
|
started_at: outcome.startedAt,
|
|
@@ -136,7 +170,7 @@ export async function runQualityBenchmarkCli(productId, options) {
|
|
|
136
170
|
if (options.save !== false) {
|
|
137
171
|
try {
|
|
138
172
|
const saved = (await callMcpEndpoint('quality_reports/save', {
|
|
139
|
-
product_id: productId,
|
|
173
|
+
product_id: repoOnly ? null : productId,
|
|
140
174
|
repository_id: repositoryId,
|
|
141
175
|
commit_sha: outcome.commitSha,
|
|
142
176
|
rubric_version: outcome.report.rubric_version,
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
*/
|
|
10
10
|
export interface RecipesCliOptions {
|
|
11
11
|
scanId: string;
|
|
12
|
+
/** Repo-only mode: scan a single repositories row with no product context. */
|
|
13
|
+
repoId?: string;
|
|
12
14
|
guidance?: string;
|
|
13
15
|
verbose?: boolean;
|
|
14
16
|
}
|
|
15
|
-
export declare function runRecipes(productId: string, options: RecipesCliOptions): Promise<void>;
|
|
17
|
+
export declare function runRecipes(productId: string | undefined, options: RecipesCliOptions): Promise<void>;
|
|
@@ -10,16 +10,22 @@
|
|
|
10
10
|
import { runRecipesPhase } from '../../phases/recipes/index.js';
|
|
11
11
|
import { logError, logInfo, logSuccess } from '../../utils/logger.js';
|
|
12
12
|
export async function runRecipes(productId, options) {
|
|
13
|
-
const { scanId, guidance, verbose } = options;
|
|
14
|
-
if (!productId) {
|
|
15
|
-
throw new Error('
|
|
13
|
+
const { scanId, repoId, guidance, verbose } = options;
|
|
14
|
+
if (!productId && !repoId) {
|
|
15
|
+
throw new Error('Either a product ID or --repo-id is required for recipes');
|
|
16
16
|
}
|
|
17
17
|
if (!scanId) {
|
|
18
18
|
throw new Error('--scan-id is required (the pending recipe_scans row id)');
|
|
19
19
|
}
|
|
20
|
-
|
|
20
|
+
if (productId) {
|
|
21
|
+
logInfo(`Starting recipes scan for product ${productId}`);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
logInfo(`Starting recipes scan for repository ${repoId}`);
|
|
25
|
+
}
|
|
21
26
|
const result = await runRecipesPhase({
|
|
22
27
|
productId,
|
|
28
|
+
repoId,
|
|
23
29
|
scanId,
|
|
24
30
|
guidance,
|
|
25
31
|
verbose,
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export interface ScreenFlowCliOptions {
|
|
12
12
|
flowId: string;
|
|
13
|
+
/** Repo-only mode: generate against a single repositories row, no product. */
|
|
14
|
+
repoId?: string;
|
|
13
15
|
guidance?: string;
|
|
14
16
|
verbose?: boolean;
|
|
15
17
|
}
|
|
16
|
-
export declare function runScreenFlow(productId: string, options: ScreenFlowCliOptions): Promise<void>;
|
|
18
|
+
export declare function runScreenFlow(productId: string | undefined, options: ScreenFlowCliOptions): Promise<void>;
|
|
@@ -12,18 +12,27 @@ import { runScreenFlowPhase } from '../../phases/screen-flow/index.js';
|
|
|
12
12
|
import { deregisterSession, registerSession, } from '../../system/session-manager.js';
|
|
13
13
|
import { logError, logInfo, logSuccess } from '../../utils/logger.js';
|
|
14
14
|
export async function runScreenFlow(productId, options) {
|
|
15
|
-
const { flowId, guidance, verbose } = options;
|
|
16
|
-
if (!productId) {
|
|
17
|
-
throw new Error('
|
|
15
|
+
const { flowId, repoId, guidance, verbose } = options;
|
|
16
|
+
if (!productId && !repoId) {
|
|
17
|
+
throw new Error('Either a product ID or --repo-id is required for screen-flow');
|
|
18
18
|
}
|
|
19
19
|
if (!flowId) {
|
|
20
20
|
throw new Error('--flow-id is required (the pending flows row id)');
|
|
21
21
|
}
|
|
22
|
-
await registerSession({
|
|
23
|
-
|
|
22
|
+
await registerSession({
|
|
23
|
+
command: 'screen-flow',
|
|
24
|
+
...(productId ? { productId } : {}),
|
|
25
|
+
});
|
|
26
|
+
if (productId) {
|
|
27
|
+
logInfo(`Starting screen flow generation for product ${productId}`);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
logInfo(`Starting screen flow generation for repository ${repoId}`);
|
|
31
|
+
}
|
|
24
32
|
try {
|
|
25
33
|
const result = await runScreenFlowPhase({
|
|
26
34
|
productId,
|
|
35
|
+
repoId,
|
|
27
36
|
flowId,
|
|
28
37
|
guidance,
|
|
29
38
|
verbose,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CLI command: `edsger sync-org-repos <teamId>`
|
|
3
3
|
*
|
|
4
|
-
* Reads the team's configured github_org
|
|
5
|
-
* via the
|
|
4
|
+
* Reads the team's configured github_org and fetches all repos from that org
|
|
5
|
+
* server-side (via the Edsger GitHub App, falling back to the user's personal
|
|
6
|
+
* GitHub token), then upserts a repositories row for each repo.
|
|
6
7
|
*/
|
|
7
8
|
export interface SyncOrgReposCliOptions {
|
|
8
9
|
verbose?: boolean;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CLI command: `edsger sync-org-repos <teamId>`
|
|
3
3
|
*
|
|
4
|
-
* Reads the team's configured github_org
|
|
5
|
-
* via the
|
|
4
|
+
* Reads the team's configured github_org and fetches all repos from that org
|
|
5
|
+
* server-side (via the Edsger GitHub App, falling back to the user's personal
|
|
6
|
+
* GitHub token), then upserts a repositories row for each repo.
|
|
6
7
|
*/
|
|
7
8
|
import { syncOrgRepos } from '../../phases/sync-org-repos/index.js';
|
|
8
9
|
import { getSupabase, hasSupabaseSession } from '../../supabase/client.js';
|
package/dist/index.js
CHANGED
|
@@ -172,15 +172,20 @@ program
|
|
|
172
172
|
// Subcommand: edsger screen-flow <productId>
|
|
173
173
|
// ============================================================
|
|
174
174
|
program
|
|
175
|
-
.command('screen-flow
|
|
176
|
-
.description('Generate a structured screen flow (screens + transitions) for a product from its source code')
|
|
175
|
+
.command('screen-flow [productId]')
|
|
176
|
+
.description('Generate a structured screen flow (screens + transitions) for a product (or standalone repository) from its source code')
|
|
177
177
|
.requiredOption('--flow-id <id>', 'Pending flows row id to populate')
|
|
178
|
+
.option('--repo-id <id>', 'Run in repo-only mode against a single repositories row (no product context)')
|
|
178
179
|
.option('-g, --guidance <text>', 'Human direction for the AI (focus areas, exclusions)')
|
|
179
180
|
.option('-v, --verbose', 'Verbose output')
|
|
180
181
|
.action(async (productId, opts) => {
|
|
181
182
|
try {
|
|
183
|
+
if (!productId && !opts.repoId) {
|
|
184
|
+
throw new Error('Provide a productId or --repo-id (repo-only mode) for screen-flow');
|
|
185
|
+
}
|
|
182
186
|
await runScreenFlow(productId, {
|
|
183
187
|
flowId: opts.flowId,
|
|
188
|
+
repoId: opts.repoId,
|
|
184
189
|
guidance: opts.guidance,
|
|
185
190
|
verbose: opts.verbose,
|
|
186
191
|
});
|
|
@@ -194,15 +199,20 @@ program
|
|
|
194
199
|
// Subcommand: edsger data-flow <productId>
|
|
195
200
|
// ============================================================
|
|
196
201
|
program
|
|
197
|
-
.command('data-flow
|
|
198
|
-
.description('Generate a structured data flow (sources, datasets, transforms, sinks, queues, models) for a product from its source code')
|
|
202
|
+
.command('data-flow [productId]')
|
|
203
|
+
.description('Generate a structured data flow (sources, datasets, transforms, sinks, queues, models) for a product (or standalone repository) from its source code')
|
|
199
204
|
.requiredOption('--flow-id <id>', 'Pending flows row id to populate')
|
|
205
|
+
.option('--repo-id <id>', 'Run in repo-only mode against a single repositories row (no product context)')
|
|
200
206
|
.option('-g, --guidance <text>', 'Human direction for the AI (focus areas, exclusions)')
|
|
201
207
|
.option('-v, --verbose', 'Verbose output')
|
|
202
208
|
.action(async (productId, opts) => {
|
|
203
209
|
try {
|
|
210
|
+
if (!productId && !opts.repoId) {
|
|
211
|
+
throw new Error('Provide a productId or --repo-id (repo-only mode) for data-flow');
|
|
212
|
+
}
|
|
204
213
|
await runDataFlow(productId, {
|
|
205
214
|
flowId: opts.flowId,
|
|
215
|
+
repoId: opts.repoId,
|
|
206
216
|
guidance: opts.guidance,
|
|
207
217
|
verbose: opts.verbose,
|
|
208
218
|
});
|
|
@@ -646,10 +656,10 @@ program
|
|
|
646
656
|
// Subcommand: edsger quality-benchmark <productId>
|
|
647
657
|
// ============================================================
|
|
648
658
|
program
|
|
649
|
-
.command('quality-benchmark
|
|
650
|
-
.description("Run an industrial-grade code quality benchmark against the product's GitHub repo")
|
|
659
|
+
.command('quality-benchmark [productId]')
|
|
660
|
+
.description("Run an industrial-grade code quality benchmark against the product's (or a standalone repository's) GitHub repo")
|
|
651
661
|
.option('--repo <path>', "Override the auto-clone with a local checkout (default: clone the product's repo into ~/edsger/quality-<owner>-<repo>)")
|
|
652
|
-
.option('--repo-id <id>', "Benchmark a specific
|
|
662
|
+
.option('--repo-id <id>', "Benchmark a specific repository (id). With a productId, targets that linked repo; without a productId, runs in repo-only mode (no product)")
|
|
653
663
|
.option('--branch <name>', 'Override the detected default branch on the report envelope')
|
|
654
664
|
.option('--pkg-manager <name>', 'npm | pnpm | yarn (auto-detected if absent)')
|
|
655
665
|
.option('--no-install', 'Refuse to install missing tools; mark them unmeasured')
|
|
@@ -659,7 +669,10 @@ program
|
|
|
659
669
|
.option('-v, --verbose', 'Print every progress event')
|
|
660
670
|
.action(async (productId, opts) => {
|
|
661
671
|
try {
|
|
662
|
-
|
|
672
|
+
if (!productId && !opts.repoId) {
|
|
673
|
+
throw new Error('Provide a productId or --repo-id (repo-only mode) for quality-benchmark');
|
|
674
|
+
}
|
|
675
|
+
await runQualityBenchmarkCli(productId ?? '', opts);
|
|
663
676
|
}
|
|
664
677
|
catch (error) {
|
|
665
678
|
logError(error instanceof Error ? error.message : String(error));
|
|
@@ -687,7 +700,7 @@ program
|
|
|
687
700
|
// ============================================================
|
|
688
701
|
program
|
|
689
702
|
.command('sync-org-repos <teamId>')
|
|
690
|
-
.description("Sync a GitHub org's repos
|
|
703
|
+
.description("Sync a GitHub org's repos under a team via the Edsger GitHub App, falling back to your personal GitHub token.")
|
|
691
704
|
.option('-v, --verbose', 'Verbose output')
|
|
692
705
|
.option('--org <name>', "GitHub org name (defaults to the team's configured github_org)")
|
|
693
706
|
.action(async (teamId, opts) => {
|
|
@@ -888,15 +901,20 @@ program
|
|
|
888
901
|
// Subcommand: edsger recipes <productId>
|
|
889
902
|
// ============================================================
|
|
890
903
|
program
|
|
891
|
-
.command('recipes
|
|
892
|
-
.description("Scan a product's repo for the implementation recipes it uses (which services/tools are chained together to deliver each capability) and persist them via the recipes / product_recipes tables. Writes against the pending recipe_scans row identified by --scan-id.")
|
|
904
|
+
.command('recipes [productId]')
|
|
905
|
+
.description("Scan a product's (or standalone repository's) repo for the implementation recipes it uses (which services/tools are chained together to deliver each capability) and persist them via the recipes / product_recipes (or repository_recipes) tables. Writes against the pending recipe_scans row identified by --scan-id.")
|
|
893
906
|
.requiredOption('--scan-id <id>', 'Pending recipe_scans row id to drive (created by the desktop UI before invocation)')
|
|
907
|
+
.option('--repo-id <id>', 'Run in repo-only mode against a single repositories row (no product context)')
|
|
894
908
|
.option('-g, --guidance <text>', 'Human direction for the AI (focus areas, exclusions)')
|
|
895
909
|
.option('-v, --verbose', 'Verbose output')
|
|
896
910
|
.action(async (productId, opts) => {
|
|
897
911
|
try {
|
|
912
|
+
if (!productId && !opts.repoId) {
|
|
913
|
+
throw new Error('Provide a productId or --repo-id (repo-only mode) for recipes');
|
|
914
|
+
}
|
|
898
915
|
await runRecipes(productId, {
|
|
899
916
|
scanId: opts.scanId,
|
|
917
|
+
repoId: opts.repoId,
|
|
900
918
|
guidance: opts.guidance,
|
|
901
919
|
verbose: opts.verbose,
|
|
902
920
|
});
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
* domain.
|
|
11
11
|
*/
|
|
12
12
|
export interface DataFlowPhaseOptions {
|
|
13
|
-
|
|
13
|
+
/** Product-scoped flow. Mutually exclusive with `repoId`. */
|
|
14
|
+
productId?: string;
|
|
15
|
+
/** Repo-only flow: a single repositories row, no product context. */
|
|
16
|
+
repoId?: string;
|
|
14
17
|
flowId: string;
|
|
15
18
|
guidance?: string;
|
|
16
19
|
verbose?: boolean;
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* domain.
|
|
11
11
|
*/
|
|
12
12
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
13
|
+
import { getRepositoryBasics } from '../../api/github.js';
|
|
13
14
|
import { DEFAULT_MODEL } from '../../constants.js';
|
|
14
15
|
import { getSupabase } from '../../supabase/client.js';
|
|
15
16
|
import { logError, logInfo, logSuccess, logWarning } from '../../utils/logger.js';
|
|
@@ -27,13 +28,20 @@ const COLUMN_WIDTH = 320;
|
|
|
27
28
|
const ROW_HEIGHT = 220;
|
|
28
29
|
const COLUMNS = 4;
|
|
29
30
|
export async function runDataFlowPhase(options) {
|
|
30
|
-
const { productId, flowId, guidance, verbose } = options;
|
|
31
|
-
|
|
31
|
+
const { productId, repoId, flowId, guidance, verbose } = options;
|
|
32
|
+
const repoOnly = !productId && Boolean(repoId);
|
|
33
|
+
if (productId) {
|
|
34
|
+
logInfo(`Starting data-flow generation for product ${productId}`);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
logInfo(`Starting data-flow generation for repository ${repoId}`);
|
|
38
|
+
}
|
|
32
39
|
const supabase = getSupabase();
|
|
33
40
|
await markFlowRunning(supabase, flowId);
|
|
34
41
|
const repositoryIds = await getFlowRepositoryIds(supabase, flowId);
|
|
35
42
|
const cloneResult = await cloneFlowRepos({
|
|
36
43
|
productId,
|
|
44
|
+
repoId,
|
|
37
45
|
repositoryIds,
|
|
38
46
|
workspaceKey: WORKSPACE_KEY,
|
|
39
47
|
verbose,
|
|
@@ -45,7 +53,9 @@ export async function runDataFlowPhase(options) {
|
|
|
45
53
|
const { projectDir, cleanupDir, repos } = cloneResult;
|
|
46
54
|
let succeeded = false;
|
|
47
55
|
try {
|
|
48
|
-
const product =
|
|
56
|
+
const product = repoOnly
|
|
57
|
+
? await resolveRepoBasics(repoId, repos)
|
|
58
|
+
: await fetchProductBasics(productId);
|
|
49
59
|
const systemPrompt = await createDataFlowSystemPrompt({
|
|
50
60
|
projectDir,
|
|
51
61
|
hasCodebase: true,
|
|
@@ -118,6 +128,17 @@ export async function runDataFlowPhase(options) {
|
|
|
118
128
|
}
|
|
119
129
|
}
|
|
120
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Build "product basics" for repo-only mode from the repositories row,
|
|
133
|
+
* falling back to the cloned repo's full name when the row has no name.
|
|
134
|
+
*/
|
|
135
|
+
async function resolveRepoBasics(repositoryId, repos) {
|
|
136
|
+
const basics = await getRepositoryBasics(repositoryId).catch(() => null);
|
|
137
|
+
return {
|
|
138
|
+
name: basics?.fullName ?? repos[0]?.fullName ?? repositoryId,
|
|
139
|
+
description: basics?.description ?? undefined,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
121
142
|
/** Read the ordered repo set a flow was scoped to (may be empty). */
|
|
122
143
|
async function getFlowRepositoryIds(supabase, flowId) {
|
|
123
144
|
const { data } = await supabase
|
|
@@ -35,8 +35,11 @@ export declare function safeDirName(fullName: string): string;
|
|
|
35
35
|
/**
|
|
36
36
|
* Resolve the repositories a flow targets (by id, preserving the stored
|
|
37
37
|
* order), falling back to the product's primary repo.
|
|
38
|
+
*
|
|
39
|
+
* In repo-only mode there is no product, so no `fallback` is provided: the
|
|
40
|
+
* set is resolved purely from `repositoryIds`.
|
|
38
41
|
*/
|
|
39
|
-
export declare function resolveTargetRepos(productId: string, repositoryIds: string[], fallback
|
|
42
|
+
export declare function resolveTargetRepos(productId: string | undefined, repositoryIds: string[], fallback?: {
|
|
40
43
|
owner: string;
|
|
41
44
|
repo: string;
|
|
42
45
|
}): Promise<{
|
|
@@ -45,7 +48,10 @@ export declare function resolveTargetRepos(productId: string, repositoryIds: str
|
|
|
45
48
|
repo: string;
|
|
46
49
|
}[]>;
|
|
47
50
|
export declare function cloneFlowRepos(opts: {
|
|
48
|
-
|
|
51
|
+
/** Product-scoped flow. Mutually exclusive with `repoId`. */
|
|
52
|
+
productId?: string;
|
|
53
|
+
/** Repo-only flow: a single repositories row, no product context. */
|
|
54
|
+
repoId?: string;
|
|
49
55
|
repositoryIds: string[];
|
|
50
56
|
workspaceKey: string;
|
|
51
57
|
verbose?: boolean;
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Falls back to the product's primary repo when `repository_ids` is empty
|
|
14
14
|
* (older flows, or single-repo products).
|
|
15
15
|
*/
|
|
16
|
-
import { getGitHubConfigByProduct } from '../../api/github.js';
|
|
16
|
+
import { getGitHubConfigByProduct, getGitHubConfigByRepository, } from '../../api/github.js';
|
|
17
17
|
import { getSupabase } from '../../supabase/client.js';
|
|
18
18
|
import { logInfo, logWarning } from '../../utils/logger.js';
|
|
19
19
|
import { cloneIssueRepo, ensureWorkspaceDir, getIssueRepoPath, } from '../../workspace/workspace-manager.js';
|
|
@@ -23,16 +23,22 @@ export function safeDirName(fullName) {
|
|
|
23
23
|
/**
|
|
24
24
|
* Resolve the repositories a flow targets (by id, preserving the stored
|
|
25
25
|
* order), falling back to the product's primary repo.
|
|
26
|
+
*
|
|
27
|
+
* In repo-only mode there is no product, so no `fallback` is provided: the
|
|
28
|
+
* set is resolved purely from `repositoryIds`.
|
|
26
29
|
*/
|
|
27
30
|
export async function resolveTargetRepos(productId, repositoryIds, fallback) {
|
|
28
31
|
if (repositoryIds.length === 0) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
if (fallback) {
|
|
33
|
+
return [
|
|
34
|
+
{
|
|
35
|
+
fullName: `${fallback.owner}/${fallback.repo}`,
|
|
36
|
+
owner: fallback.owner,
|
|
37
|
+
repo: fallback.repo,
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
return [];
|
|
36
42
|
}
|
|
37
43
|
const supabase = getSupabase();
|
|
38
44
|
const { data } = await supabase
|
|
@@ -54,8 +60,8 @@ export async function resolveTargetRepos(productId, repositoryIds, fallback) {
|
|
|
54
60
|
resolved.push({ fullName, owner, repo });
|
|
55
61
|
}
|
|
56
62
|
// If none resolved (deleted repos / RLS), fall back to the primary repo so
|
|
57
|
-
// generation still produces something useful.
|
|
58
|
-
if (resolved.length === 0) {
|
|
63
|
+
// generation still produces something useful (product mode only).
|
|
64
|
+
if (resolved.length === 0 && fallback) {
|
|
59
65
|
return [
|
|
60
66
|
{
|
|
61
67
|
fullName: `${fallback.owner}/${fallback.repo}`,
|
|
@@ -67,21 +73,33 @@ export async function resolveTargetRepos(productId, repositoryIds, fallback) {
|
|
|
67
73
|
return resolved;
|
|
68
74
|
}
|
|
69
75
|
export async function cloneFlowRepos(opts) {
|
|
70
|
-
const { productId, repositoryIds, workspaceKey, verbose } = opts;
|
|
71
|
-
const
|
|
76
|
+
const { productId, repoId, repositoryIds, workspaceKey, verbose } = opts;
|
|
77
|
+
const repoOnly = !productId && Boolean(repoId);
|
|
78
|
+
// Resolve the auth token. Product mode reuses the product's installation /
|
|
79
|
+
// PAT for every repo; repo-only mode resolves it from the first (only) repo.
|
|
80
|
+
const gh = repoOnly
|
|
81
|
+
? await getGitHubConfigByRepository(repositoryIds[0] ?? repoId, verbose)
|
|
82
|
+
: await getGitHubConfigByProduct(productId, verbose);
|
|
72
83
|
if (!gh.configured || !gh.token || !gh.owner || !gh.repo) {
|
|
73
84
|
return {
|
|
74
85
|
ok: false,
|
|
75
86
|
message: gh.message ||
|
|
76
|
-
|
|
87
|
+
(repoOnly
|
|
88
|
+
? 'GitHub repository not configured. Connect the repo first.'
|
|
89
|
+
: 'GitHub repository not configured for this product. Connect a repo first.'),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
// In repo-only mode there is no product primary-repo fallback; targets come
|
|
93
|
+
// purely from repositoryIds.
|
|
94
|
+
const targets = await resolveTargetRepos(productId, repositoryIds, repoOnly ? undefined : { owner: gh.owner, repo: gh.repo });
|
|
95
|
+
if (targets.length === 0) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
message: 'No repositories resolved for this flow.',
|
|
77
99
|
};
|
|
78
100
|
}
|
|
79
|
-
const targets = await resolveTargetRepos(productId, repositoryIds, {
|
|
80
|
-
owner: gh.owner,
|
|
81
|
-
repo: gh.repo,
|
|
82
|
-
});
|
|
83
101
|
const workspaceRoot = ensureWorkspaceDir();
|
|
84
|
-
const parentDir = getIssueRepoPath(workspaceRoot, `${workspaceKey}-${productId}`);
|
|
102
|
+
const parentDir = getIssueRepoPath(workspaceRoot, `${workspaceKey}-${repoOnly ? `repo-${repoId}` : productId}`);
|
|
85
103
|
const repos = [];
|
|
86
104
|
for (const target of targets) {
|
|
87
105
|
try {
|
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
20
20
|
import type { RecipeSummary } from './types.js';
|
|
21
21
|
export interface RecipesPhaseOptions {
|
|
22
|
-
|
|
22
|
+
/** Product-scoped scan. Mutually exclusive with `repoId`. */
|
|
23
|
+
productId?: string;
|
|
24
|
+
/** Repo-only scan: a single repositories row, no product context. */
|
|
25
|
+
repoId?: string;
|
|
23
26
|
scanId: string;
|
|
24
27
|
guidance?: string;
|
|
25
28
|
verbose?: boolean;
|
|
@@ -44,6 +47,10 @@ export declare function listProductRecipeLinks(supabase: SupabaseClient, product
|
|
|
44
47
|
recipe_id: string;
|
|
45
48
|
name: string;
|
|
46
49
|
}[]>;
|
|
50
|
+
export declare function listRepositoryRecipeLinks(supabase: SupabaseClient, repositoryId: string): Promise<{
|
|
51
|
+
recipe_id: string;
|
|
52
|
+
name: string;
|
|
53
|
+
}[]>;
|
|
47
54
|
/**
|
|
48
55
|
* Claim the row by flipping `pending` → `running`. Returns true on success
|
|
49
56
|
* (we won the claim) and false when the row has already moved on (e.g. user
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* progress survives even if the agent later errors out.
|
|
18
18
|
*/
|
|
19
19
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
20
|
-
import { getGitHubConfigByProduct } from '../../api/github.js';
|
|
20
|
+
import { getGitHubConfigByProduct, getGitHubConfigByRepository, getRepositoryBasics, } from '../../api/github.js';
|
|
21
21
|
import { DEFAULT_MODEL } from '../../constants.js';
|
|
22
22
|
import { getSupabase } from '../../supabase/client.js';
|
|
23
23
|
import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
|
|
@@ -33,8 +33,14 @@ const MAX_TURNS = 200;
|
|
|
33
33
|
// flowing) lets the row go stale and the reader can mark it failed.
|
|
34
34
|
const HEARTBEAT_MIN_INTERVAL_MS = 15_000;
|
|
35
35
|
export async function runRecipesPhase(options) {
|
|
36
|
-
const { productId, scanId, guidance, verbose } = options;
|
|
37
|
-
|
|
36
|
+
const { productId, repoId, scanId, guidance, verbose } = options;
|
|
37
|
+
const repoOnly = !productId && Boolean(repoId);
|
|
38
|
+
if (productId) {
|
|
39
|
+
logInfo(`Starting recipes scan for product ${productId}`);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
logInfo(`Starting recipes scan for repository ${repoId}`);
|
|
43
|
+
}
|
|
38
44
|
const supabase = getSupabase();
|
|
39
45
|
const claimed = await markRunning(supabase, scanId);
|
|
40
46
|
if (!claimed) {
|
|
@@ -43,19 +49,42 @@ export async function runRecipesPhase(options) {
|
|
|
43
49
|
message: 'Recipe scan row is no longer in a runnable state (likely cancelled before the CLI started)',
|
|
44
50
|
};
|
|
45
51
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
// Resolve the team (recipes are team-scoped) + repo basics. In repo-only
|
|
53
|
+
// mode both come off the repositories row; in product mode from the product.
|
|
54
|
+
let teamId;
|
|
55
|
+
let repoBasics = {
|
|
56
|
+
fullName: null,
|
|
57
|
+
description: null,
|
|
58
|
+
};
|
|
59
|
+
if (repoOnly) {
|
|
60
|
+
const basics = await getRepositoryBasics(repoId);
|
|
61
|
+
teamId = basics.teamId;
|
|
62
|
+
repoBasics = { fullName: basics.fullName, description: basics.description };
|
|
63
|
+
if (!teamId) {
|
|
64
|
+
const msg = 'Repository is not associated with a team; recipes are team-scoped and require one.';
|
|
65
|
+
await markFailed(supabase, scanId, msg);
|
|
66
|
+
return { status: 'error', message: msg };
|
|
67
|
+
}
|
|
51
68
|
}
|
|
52
|
-
|
|
69
|
+
else {
|
|
70
|
+
teamId = await getProductTeamId(supabase, productId);
|
|
71
|
+
if (!teamId) {
|
|
72
|
+
const msg = 'Product is not associated with a team; recipes are team-scoped and require one.';
|
|
73
|
+
await markFailed(supabase, scanId, msg);
|
|
74
|
+
return { status: 'error', message: msg };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const githubConfig = repoOnly
|
|
78
|
+
? await getGitHubConfigByRepository(repoId, verbose)
|
|
79
|
+
: await getGitHubConfigByProduct(productId, verbose);
|
|
53
80
|
if (!githubConfig.configured ||
|
|
54
81
|
!githubConfig.token ||
|
|
55
82
|
!githubConfig.owner ||
|
|
56
83
|
!githubConfig.repo) {
|
|
57
84
|
const msg = githubConfig.message ||
|
|
58
|
-
|
|
85
|
+
(repoOnly
|
|
86
|
+
? 'GitHub repository not configured. Connect the repo first.'
|
|
87
|
+
: 'GitHub repository not configured for this product. Connect a repo first.');
|
|
59
88
|
await markFailed(supabase, scanId, msg);
|
|
60
89
|
return { status: 'error', message: msg };
|
|
61
90
|
}
|
|
@@ -63,13 +92,22 @@ export async function runRecipesPhase(options) {
|
|
|
63
92
|
let succeeded = false;
|
|
64
93
|
try {
|
|
65
94
|
const workspaceRoot = ensureWorkspaceDir();
|
|
66
|
-
const repoKey =
|
|
95
|
+
const repoKey = repoOnly
|
|
96
|
+
? `${WORKSPACE_KEY}-repo-${repoId}`
|
|
97
|
+
: `${WORKSPACE_KEY}-${productId}`;
|
|
67
98
|
({ repoPath } = cloneIssueRepo(workspaceRoot, repoKey, githubConfig.owner, githubConfig.repo, githubConfig.token));
|
|
68
|
-
const [
|
|
69
|
-
|
|
99
|
+
const [basics, scanMeta, teamRecipes, existingLinks] = await Promise.all([
|
|
100
|
+
repoOnly
|
|
101
|
+
? Promise.resolve({
|
|
102
|
+
name: repoBasics.fullName ?? `${githubConfig.owner}/${githubConfig.repo}`,
|
|
103
|
+
description: repoBasics.description ?? undefined,
|
|
104
|
+
})
|
|
105
|
+
: fetchProductBasics(productId),
|
|
70
106
|
getScanCreator(supabase, scanId),
|
|
71
107
|
listTeamRecipes(supabase, teamId),
|
|
72
|
-
|
|
108
|
+
repoOnly
|
|
109
|
+
? listRepositoryRecipeLinks(supabase, repoId)
|
|
110
|
+
: listProductRecipeLinks(supabase, productId),
|
|
73
111
|
]);
|
|
74
112
|
if (!scanMeta) {
|
|
75
113
|
const msg = 'recipe_scans row vanished mid-run; aborting';
|
|
@@ -78,8 +116,8 @@ export async function runRecipesPhase(options) {
|
|
|
78
116
|
}
|
|
79
117
|
const systemPrompt = createRecipesSystemPrompt();
|
|
80
118
|
const userPrompt = createRecipesUserPrompt({
|
|
81
|
-
productName:
|
|
82
|
-
productDescription:
|
|
119
|
+
productName: basics.name,
|
|
120
|
+
productDescription: basics.description,
|
|
83
121
|
guidance,
|
|
84
122
|
teamRecipes,
|
|
85
123
|
existingLinks: existingLinks.map((l) => ({
|
|
@@ -92,7 +130,8 @@ export async function runRecipesPhase(options) {
|
|
|
92
130
|
const mcpServer = createRecipesMcpServer({
|
|
93
131
|
supabase,
|
|
94
132
|
teamId,
|
|
95
|
-
productId,
|
|
133
|
+
productId: repoOnly ? undefined : productId,
|
|
134
|
+
repositoryId: repoOnly ? repoId : undefined,
|
|
96
135
|
createdBy: scanMeta.created_by,
|
|
97
136
|
}, counts, teamRecipes, existingLinkIds);
|
|
98
137
|
logInfo('Running Claude agent to identify recipes...');
|
|
@@ -229,6 +268,24 @@ export async function listProductRecipeLinks(supabase, productId) {
|
|
|
229
268
|
}
|
|
230
269
|
return out;
|
|
231
270
|
}
|
|
271
|
+
export async function listRepositoryRecipeLinks(supabase, repositoryId) {
|
|
272
|
+
const { data, error } = await supabase
|
|
273
|
+
.from('repository_recipes')
|
|
274
|
+
.select('recipe_id, recipes(name)')
|
|
275
|
+
.eq('repository_id', repositoryId);
|
|
276
|
+
if (error || !data) {
|
|
277
|
+
return [];
|
|
278
|
+
}
|
|
279
|
+
const rows = data;
|
|
280
|
+
const out = [];
|
|
281
|
+
for (const r of rows) {
|
|
282
|
+
const recipe = Array.isArray(r.recipes) ? r.recipes[0] : r.recipes;
|
|
283
|
+
if (recipe) {
|
|
284
|
+
out.push({ recipe_id: r.recipe_id, name: recipe.name });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
232
289
|
/**
|
|
233
290
|
* Claim the row by flipping `pending` → `running`. Returns true on success
|
|
234
291
|
* (we won the claim) and false when the row has already moved on (e.g. user
|
|
@@ -22,7 +22,10 @@ import { type RecipeSummary } from './types.js';
|
|
|
22
22
|
export interface RecipesToolContext {
|
|
23
23
|
supabase: SupabaseClient;
|
|
24
24
|
teamId: string;
|
|
25
|
-
|
|
25
|
+
/** Set in product-scoped scans; links go to `product_recipes`. */
|
|
26
|
+
productId?: string;
|
|
27
|
+
/** Set in repo-only scans; links go to `repository_recipes`. */
|
|
28
|
+
repositoryId?: string;
|
|
26
29
|
createdBy: string;
|
|
27
30
|
}
|
|
28
31
|
/**
|
|
@@ -19,6 +19,27 @@
|
|
|
19
19
|
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
|
|
20
20
|
import { z } from 'zod';
|
|
21
21
|
import { RECIPE_CONTENT_MAX, RECIPE_EVIDENCE_MAX, RECIPE_NAME_MAX, RECIPE_SERVICE_NAME_MAX, RECIPE_SERVICES_MAX, RECIPE_SUMMARY_MAX, } from './types.js';
|
|
22
|
+
function resolveLinkScope(ctx) {
|
|
23
|
+
if (ctx.repositoryId) {
|
|
24
|
+
return {
|
|
25
|
+
table: 'repository_recipes',
|
|
26
|
+
column: 'repository_id',
|
|
27
|
+
id: ctx.repositoryId,
|
|
28
|
+
onConflict: 'repository_id,recipe_id',
|
|
29
|
+
label: 'this repository',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (ctx.productId) {
|
|
33
|
+
return {
|
|
34
|
+
table: 'product_recipes',
|
|
35
|
+
column: 'product_id',
|
|
36
|
+
id: ctx.productId,
|
|
37
|
+
onConflict: 'product_id,recipe_id',
|
|
38
|
+
label: 'this product',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
throw new Error('RecipesToolContext requires either productId or repositoryId to scope links');
|
|
42
|
+
}
|
|
22
43
|
export function createRecipesMutationCounts() {
|
|
23
44
|
return { created: 0, updated: 0, linked: 0, unlinked: 0 };
|
|
24
45
|
}
|
|
@@ -92,17 +113,18 @@ export function createCreateRecipeTool(ctx, counts, teamRecipeIds) {
|
|
|
92
113
|
return textError(`Failed to create recipe: ${insertRecipe.error?.message ?? 'unknown error'}`);
|
|
93
114
|
}
|
|
94
115
|
const recipeId = insertRecipe.data.id;
|
|
95
|
-
const
|
|
96
|
-
|
|
116
|
+
const scope = resolveLinkScope(ctx);
|
|
117
|
+
const linkErr = await ctx.supabase.from(scope.table).insert({
|
|
118
|
+
[scope.column]: scope.id,
|
|
97
119
|
recipe_id: recipeId,
|
|
98
120
|
evidence: args.evidence.trim(),
|
|
99
121
|
});
|
|
100
122
|
if (linkErr.error) {
|
|
101
|
-
return textError(`Recipe created (${recipeId}) but linking to
|
|
123
|
+
return textError(`Recipe created (${recipeId}) but linking to ${scope.label} failed: ${linkErr.error.message}`);
|
|
102
124
|
}
|
|
103
125
|
teamRecipeIds.add(recipeId);
|
|
104
126
|
counts.created += 1;
|
|
105
|
-
return textOk(`Created recipe ${recipeId} and linked to
|
|
127
|
+
return textOk(`Created recipe ${recipeId} and linked to ${scope.label}.`);
|
|
106
128
|
});
|
|
107
129
|
}
|
|
108
130
|
export function createUpdateRecipeTool(ctx, counts, teamRecipeIds) {
|
|
@@ -136,16 +158,17 @@ export function createUpdateRecipeTool(ctx, counts, teamRecipeIds) {
|
|
|
136
158
|
return textError(`Failed to update recipe: ${updateErr.error.message}`);
|
|
137
159
|
}
|
|
138
160
|
}
|
|
139
|
-
const
|
|
140
|
-
|
|
161
|
+
const scope = resolveLinkScope(ctx);
|
|
162
|
+
const linkErr = await ctx.supabase.from(scope.table).upsert({
|
|
163
|
+
[scope.column]: scope.id,
|
|
141
164
|
recipe_id: args.recipe_id,
|
|
142
165
|
evidence: args.evidence.trim(),
|
|
143
|
-
}, { onConflict:
|
|
166
|
+
}, { onConflict: scope.onConflict });
|
|
144
167
|
if (linkErr.error) {
|
|
145
|
-
return textError(`Recipe updated but linking to
|
|
168
|
+
return textError(`Recipe updated but linking to ${scope.label} failed: ${linkErr.error.message}`);
|
|
146
169
|
}
|
|
147
170
|
counts.updated += 1;
|
|
148
|
-
return textOk(`Updated recipe ${args.recipe_id} and linked to
|
|
171
|
+
return textOk(`Updated recipe ${args.recipe_id} and linked to ${scope.label}.`);
|
|
149
172
|
});
|
|
150
173
|
}
|
|
151
174
|
export function createLinkRecipeTool(ctx, counts, teamRecipeIds) {
|
|
@@ -156,36 +179,38 @@ export function createLinkRecipeTool(ctx, counts, teamRecipeIds) {
|
|
|
156
179
|
if (!teamRecipeIds.has(args.recipe_id)) {
|
|
157
180
|
return textError(`recipe_id ${args.recipe_id} is not in this team's recipe list. Use create_recipe for new entries.`);
|
|
158
181
|
}
|
|
159
|
-
const
|
|
160
|
-
|
|
182
|
+
const scope = resolveLinkScope(ctx);
|
|
183
|
+
const linkErr = await ctx.supabase.from(scope.table).upsert({
|
|
184
|
+
[scope.column]: scope.id,
|
|
161
185
|
recipe_id: args.recipe_id,
|
|
162
186
|
evidence: args.evidence.trim(),
|
|
163
|
-
}, { onConflict:
|
|
187
|
+
}, { onConflict: scope.onConflict });
|
|
164
188
|
if (linkErr.error) {
|
|
165
189
|
return textError(`Failed to link recipe: ${linkErr.error.message}`);
|
|
166
190
|
}
|
|
167
191
|
counts.linked += 1;
|
|
168
|
-
return textOk(`Linked recipe ${args.recipe_id} to
|
|
192
|
+
return textOk(`Linked recipe ${args.recipe_id} to ${scope.label}.`);
|
|
169
193
|
});
|
|
170
194
|
}
|
|
171
195
|
export function createUnlinkRecipeTool(ctx, counts, existingLinkIds) {
|
|
172
|
-
return tool('unlink_recipe', 'Drop a previously-linked recipe that you have confirmed is NO LONGER present in this repo. Only call this for ids in the "Currently linked
|
|
196
|
+
return tool('unlink_recipe', 'Drop a previously-linked recipe that you have confirmed is NO LONGER present in this repo. Only call this for ids in the "Currently linked" list. Does not delete the recipe itself.', {
|
|
173
197
|
recipe_id: z.string().uuid(),
|
|
174
198
|
}, async (args) => {
|
|
175
199
|
if (!existingLinkIds.has(args.recipe_id)) {
|
|
176
|
-
return textError(`recipe_id ${args.recipe_id} is not in the "Currently linked
|
|
200
|
+
return textError(`recipe_id ${args.recipe_id} is not in the "Currently linked" list — nothing to unlink.`);
|
|
177
201
|
}
|
|
202
|
+
const scope = resolveLinkScope(ctx);
|
|
178
203
|
const { error } = await ctx.supabase
|
|
179
|
-
.from(
|
|
204
|
+
.from(scope.table)
|
|
180
205
|
.delete()
|
|
181
|
-
.eq(
|
|
206
|
+
.eq(scope.column, scope.id)
|
|
182
207
|
.eq('recipe_id', args.recipe_id);
|
|
183
208
|
if (error) {
|
|
184
209
|
return textError(`Failed to unlink recipe: ${error.message}`);
|
|
185
210
|
}
|
|
186
211
|
existingLinkIds.delete(args.recipe_id);
|
|
187
212
|
counts.unlinked += 1;
|
|
188
|
-
return textOk(`Unlinked recipe ${args.recipe_id} from
|
|
213
|
+
return textOk(`Unlinked recipe ${args.recipe_id} from ${scope.label}.`);
|
|
189
214
|
});
|
|
190
215
|
}
|
|
191
216
|
export function createRecipesMcpServer(ctx, counts, teamRecipes, existingLinkIds) {
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
* pattern, but writes to its own tables rather than filing issues.
|
|
9
9
|
*/
|
|
10
10
|
export interface ScreenFlowOptions {
|
|
11
|
-
|
|
11
|
+
/** Product-scoped flow. Mutually exclusive with `repoId`. */
|
|
12
|
+
productId?: string;
|
|
13
|
+
/** Repo-only flow: a single repositories row, no product context. */
|
|
14
|
+
repoId?: string;
|
|
12
15
|
flowId: string;
|
|
13
16
|
guidance?: string;
|
|
14
17
|
verbose?: boolean;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* pattern, but writes to its own tables rather than filing issues.
|
|
9
9
|
*/
|
|
10
10
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
11
|
+
import { getRepositoryBasics } from '../../api/github.js';
|
|
11
12
|
import { DEFAULT_MODEL } from '../../constants.js';
|
|
12
13
|
import { getSupabase } from '../../supabase/client.js';
|
|
13
14
|
import { logError, logInfo, logSuccess, logWarning } from '../../utils/logger.js';
|
|
@@ -26,13 +27,20 @@ const COLUMN_WIDTH = 380;
|
|
|
26
27
|
const ROW_HEIGHT = 480;
|
|
27
28
|
const COLUMNS = 4;
|
|
28
29
|
export async function runScreenFlowPhase(options) {
|
|
29
|
-
const { productId, flowId, guidance, verbose } = options;
|
|
30
|
-
|
|
30
|
+
const { productId, repoId, flowId, guidance, verbose } = options;
|
|
31
|
+
const repoOnly = !productId && Boolean(repoId);
|
|
32
|
+
if (productId) {
|
|
33
|
+
logInfo(`Starting screen-flow generation for product ${productId}`);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
logInfo(`Starting screen-flow generation for repository ${repoId}`);
|
|
37
|
+
}
|
|
31
38
|
const supabase = getSupabase();
|
|
32
39
|
await markFlowRunning(supabase, flowId);
|
|
33
40
|
const repositoryIds = await getFlowRepositoryIds(supabase, flowId);
|
|
34
41
|
const cloneResult = await cloneFlowRepos({
|
|
35
42
|
productId,
|
|
43
|
+
repoId,
|
|
36
44
|
repositoryIds,
|
|
37
45
|
workspaceKey: WORKSPACE_KEY,
|
|
38
46
|
verbose,
|
|
@@ -44,7 +52,9 @@ export async function runScreenFlowPhase(options) {
|
|
|
44
52
|
const { projectDir, cleanupDir, repos } = cloneResult;
|
|
45
53
|
let succeeded = false;
|
|
46
54
|
try {
|
|
47
|
-
const product =
|
|
55
|
+
const product = repoOnly
|
|
56
|
+
? await resolveRepoBasics(repoId, repos)
|
|
57
|
+
: await fetchProductBasics(productId);
|
|
48
58
|
const systemPrompt = await createScreenFlowSystemPrompt({
|
|
49
59
|
projectDir,
|
|
50
60
|
hasCodebase: true,
|
|
@@ -148,6 +158,17 @@ function resolveTheme(agentTheme, repos) {
|
|
|
148
158
|
}
|
|
149
159
|
return {};
|
|
150
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Build "product basics" for repo-only mode from the repositories row,
|
|
163
|
+
* falling back to the cloned repo's full name when the row has no name.
|
|
164
|
+
*/
|
|
165
|
+
async function resolveRepoBasics(repositoryId, repos) {
|
|
166
|
+
const basics = await getRepositoryBasics(repositoryId).catch(() => null);
|
|
167
|
+
return {
|
|
168
|
+
name: basics?.fullName ?? repos[0]?.fullName ?? repositoryId,
|
|
169
|
+
description: basics?.description ?? undefined,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
151
172
|
/** Read the ordered repo set a flow was scoped to (may be empty). */
|
|
152
173
|
async function getFlowRepositoryIds(supabase, flowId) {
|
|
153
174
|
const { data } = await supabase
|