edsger 0.63.1 → 0.64.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/commands/session-turn/index.js +57 -66
- package/dist/commands/sync-aws/index.js +44 -14
- package/dist/commands/sync-datadog/index.js +58 -21
- package/dist/commands/sync-org-repos/index.js +1 -1
- package/dist/commands/sync-terraform/index.js +47 -17
- package/dist/phases/chat-processor/product-prompts.d.ts +1 -1
- package/dist/phases/chat-processor/product-prompts.js +10 -0
- package/dist/phases/flow-shared/clone-repos.d.ts +13 -0
- package/dist/phases/flow-shared/clone-repos.js +7 -7
- package/dist/phases/sync-org-repos/index.js +2 -1
- package/dist/workspace/session-workspace.d.ts +43 -0
- package/dist/workspace/session-workspace.js +90 -0
- package/eslint.config.mjs +4 -0
- package/package.json +3 -3
- package/vitest.config.ts +11 -18
|
@@ -17,53 +17,33 @@ export const SESSION_ID_MARKER = '__EDSGER_SESSION_ID__=';
|
|
|
17
17
|
* there is no fixed pipeline.
|
|
18
18
|
*/
|
|
19
19
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
20
|
-
import { createSessionMcpServer } from 'edsger-tools';
|
|
20
|
+
import { buildSessionAgentOptions, buildSessionSystemPrompt, buildSessionUserPrompt, createSessionMcpServer, loadExternalMcpServers, SESSION_MAX_TURNS, } from 'edsger-tools';
|
|
21
21
|
import { callMcpEndpoint } from '../../api/mcp-client.js';
|
|
22
22
|
import { DEFAULT_MODEL } from '../../constants.js';
|
|
23
23
|
import { getToolDeps } from '../../tools/bootstrap.js';
|
|
24
|
-
import { logError, logInfo, logSuccess } from '../../utils/logger.js';
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
2. create_issue with a clear, implementable description (the product may span multiple repos — say which).
|
|
48
|
-
3. Mark it ready for AI with update_issue_status so the engineering pipeline picks it up, clones the repos, implements the change, and opens the pull request(s).
|
|
49
|
-
4. Tell the user you filed the issue and that the pipeline will produce the PR(s); offer to check status later.
|
|
50
|
-
Only create issues/tasks when the work warrants tracking — a pure question or quality check should not leave an issue behind.
|
|
51
|
-
|
|
52
|
-
## Talking to the user
|
|
53
|
-
- send_chat_message for explanations, summaries, clarifying questions (channel_id "${channelId}").
|
|
54
|
-
- provide_options when there are 2-4 clear next steps.
|
|
55
|
-
- Be concise; report what you did and what you need.`;
|
|
56
|
-
}
|
|
57
|
-
function buildUserPrompt(messages) {
|
|
58
|
-
const conversation = messages
|
|
59
|
-
.map((m) => {
|
|
60
|
-
const who = m.sender_agent_id
|
|
61
|
-
? `[Agent: ${m.sender_name || 'Unknown'}]`
|
|
62
|
-
: `[${m.sender_name || 'User'}]`;
|
|
63
|
-
return `${who}: ${m.content}`;
|
|
64
|
-
})
|
|
65
|
-
.join('\n\n');
|
|
66
|
-
return `New message(s) in this session:\n\n${conversation}\n\nDecide what to do and act. Keep the user informed via send_chat_message.`;
|
|
24
|
+
import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
|
|
25
|
+
import { cloneSessionRepos, describeSessionRepos, } from '../../workspace/session-workspace.js';
|
|
26
|
+
/**
|
|
27
|
+
* Clone (or refresh) the session product's repositories into a local session
|
|
28
|
+
* directory and produce the agent's cwd + a prompt note describing the layout.
|
|
29
|
+
*
|
|
30
|
+
* One session dir holds every repo as a subdirectory; clones are reused across
|
|
31
|
+
* turns. Best-effort: if nothing can be cloned (no GitHub config, private repo
|
|
32
|
+
* the token can't reach), the agent still runs — it can answer, file issues,
|
|
33
|
+
* etc. — just without a checked-out codebase.
|
|
34
|
+
*/
|
|
35
|
+
async function prepareSessionWorkspace(opts) {
|
|
36
|
+
let workspace = null;
|
|
37
|
+
try {
|
|
38
|
+
workspace = await cloneSessionRepos(opts);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
logWarning(`Could not prepare session repos: ${error instanceof Error ? error.message : String(error)}`);
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
sessionDir: workspace?.sessionDir,
|
|
45
|
+
repoScopeNote: workspace ? describeSessionRepos(workspace.repos) : '',
|
|
46
|
+
};
|
|
67
47
|
}
|
|
68
48
|
export async function runSessionTurnCommand(options) {
|
|
69
49
|
const { channelId, productId, repositoryIds = [], resumeSessionId, verbose = false, } = options;
|
|
@@ -83,40 +63,51 @@ export async function runSessionTurnCommand(options) {
|
|
|
83
63
|
return;
|
|
84
64
|
}
|
|
85
65
|
logInfo(`Processing ${messages.length} message(s) for session ${channelId}`);
|
|
86
|
-
// 2.
|
|
66
|
+
// 2. Clone the product's in-scope repositories into a local session
|
|
67
|
+
// directory so the agent's Read/Grep/Glob can inspect the real code.
|
|
68
|
+
const { sessionDir, repoScopeNote } = await prepareSessionWorkspace({
|
|
69
|
+
channelId,
|
|
70
|
+
productId,
|
|
71
|
+
repositoryIds,
|
|
72
|
+
verbose,
|
|
73
|
+
});
|
|
74
|
+
// 3. Build the session toolbelt + prompts.
|
|
87
75
|
const deps = getToolDeps({
|
|
88
76
|
verbose,
|
|
89
77
|
context: { productId, channelId, repositoryIds },
|
|
90
78
|
});
|
|
91
79
|
const sessionServer = createSessionMcpServer(deps);
|
|
92
|
-
|
|
80
|
+
// User-configured external MCP servers (~/.edsger/mcp.json). Best-effort.
|
|
81
|
+
const external = loadExternalMcpServers({ warn: logWarning });
|
|
82
|
+
if (external.names.length > 0) {
|
|
83
|
+
logInfo(`External MCP servers: ${external.names.join(', ')}`);
|
|
84
|
+
}
|
|
85
|
+
const systemPrompt = buildSessionSystemPrompt({
|
|
93
86
|
channelId,
|
|
94
87
|
productId,
|
|
95
88
|
repositoryIds,
|
|
89
|
+
repoScopeNote,
|
|
90
|
+
externalMcpNames: external.names,
|
|
96
91
|
});
|
|
97
|
-
const userPrompt =
|
|
98
|
-
//
|
|
99
|
-
// the conversation (and prompt cache) carries across turns.
|
|
92
|
+
const userPrompt = buildSessionUserPrompt(messages);
|
|
93
|
+
// 4. Run one SDK turn. Resume the prior SDK session when we have its id so
|
|
94
|
+
// the conversation (and prompt cache) carries across turns. Point the
|
|
95
|
+
// agent's cwd at the session directory so its read-only file tools resolve
|
|
96
|
+
// against the cloned repos. Prompt + tool/MCP wiring come from the shared
|
|
97
|
+
// session core (edsger-tools) so CLI and worker can't drift apart.
|
|
100
98
|
let finalResponse = '';
|
|
101
99
|
let sdkSessionId = resumeSessionId ?? '';
|
|
102
100
|
try {
|
|
103
101
|
for await (const message of query({
|
|
104
102
|
prompt: userPrompt,
|
|
105
103
|
options: {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
disallowedTools: ['Edit', 'Write', 'Bash', 'NotebookEdit', 'Agent'],
|
|
114
|
-
},
|
|
115
|
-
},
|
|
116
|
-
mcpServers: { 'edsger-session': sessionServer },
|
|
117
|
-
tools: ['Read', 'Grep', 'Glob'],
|
|
118
|
-
permissionMode: 'bypassPermissions',
|
|
119
|
-
allowDangerouslySkipPermissions: true,
|
|
104
|
+
...buildSessionAgentOptions(systemPrompt, {
|
|
105
|
+
sessionServer,
|
|
106
|
+
externalServers: external.servers,
|
|
107
|
+
externalNames: external.names,
|
|
108
|
+
sessionDir,
|
|
109
|
+
maxTurns: SESSION_MAX_TURNS,
|
|
110
|
+
}),
|
|
120
111
|
model: DEFAULT_MODEL,
|
|
121
112
|
...(resumeSessionId ? { resume: resumeSessionId } : {}),
|
|
122
113
|
},
|
|
@@ -149,7 +140,7 @@ export async function runSessionTurnCommand(options) {
|
|
|
149
140
|
await markProcessed(messages);
|
|
150
141
|
return;
|
|
151
142
|
}
|
|
152
|
-
//
|
|
143
|
+
// 5. Post the final reply (the agent may also have posted via
|
|
153
144
|
// send_chat_message during the turn; this carries the closing summary).
|
|
154
145
|
const reply = finalResponse.trim();
|
|
155
146
|
if (reply) {
|
|
@@ -162,9 +153,9 @@ export async function runSessionTurnCommand(options) {
|
|
|
162
153
|
logError(`Failed to post reply: ${e instanceof Error ? e.message : String(e)}`);
|
|
163
154
|
});
|
|
164
155
|
}
|
|
165
|
-
//
|
|
156
|
+
// 6. Mark the triggering messages processed so they aren't re-run.
|
|
166
157
|
await markProcessed(messages);
|
|
167
|
-
//
|
|
158
|
+
// 7. Emit the SDK session id so the desktop persists it for the next turn.
|
|
168
159
|
emitSessionId(sdkSessionId);
|
|
169
160
|
logSuccess('Session turn complete.');
|
|
170
161
|
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { exec as execCb } from 'child_process';
|
|
12
12
|
import { promisify } from 'util';
|
|
13
13
|
import { callMcpEndpoint } from '../../api/mcp-client.js';
|
|
14
|
-
import { logError, logInfo, logSuccess, logWarning } from '../../utils/logger.js';
|
|
14
|
+
import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
|
|
15
15
|
const exec = promisify(execCb);
|
|
16
16
|
async function runAwsCmd(args, region) {
|
|
17
17
|
const regionFlag = region ? ` --region ${region}` : '';
|
|
@@ -37,7 +37,10 @@ async function checkAwsCli() {
|
|
|
37
37
|
}
|
|
38
38
|
export async function runSyncAws(teamId, options = {}) {
|
|
39
39
|
const { verbose, noCost } = options;
|
|
40
|
-
const region = options.region ??
|
|
40
|
+
const region = options.region ??
|
|
41
|
+
process.env.AWS_REGION ??
|
|
42
|
+
process.env.AWS_DEFAULT_REGION ??
|
|
43
|
+
'us-east-1';
|
|
41
44
|
logInfo(`Starting AWS sync for team ${teamId} (region: ${region})`);
|
|
42
45
|
if (!(await checkAwsCli())) {
|
|
43
46
|
logError('AWS CLI not found. Install it from https://aws.amazon.com/cli/ ' +
|
|
@@ -57,7 +60,7 @@ export async function runSyncAws(teamId, options = {}) {
|
|
|
57
60
|
}
|
|
58
61
|
// ── Phase 1: Resource discovery by Service tag ──
|
|
59
62
|
logInfo('Phase 1: Discovering resources by Service tag...');
|
|
60
|
-
|
|
63
|
+
const resources = [];
|
|
61
64
|
try {
|
|
62
65
|
let paginationToken;
|
|
63
66
|
do {
|
|
@@ -79,10 +82,12 @@ export async function runSyncAws(teamId, options = {}) {
|
|
|
79
82
|
const byService = new Map();
|
|
80
83
|
for (const res of resources) {
|
|
81
84
|
const serviceTag = res.Tags.find((t) => t.Key === 'Service')?.Value;
|
|
82
|
-
if (!serviceTag)
|
|
85
|
+
if (!serviceTag) {
|
|
83
86
|
continue;
|
|
84
|
-
|
|
87
|
+
}
|
|
88
|
+
if (!byService.has(serviceTag)) {
|
|
85
89
|
byService.set(serviceTag, []);
|
|
90
|
+
}
|
|
86
91
|
byService.get(serviceTag).push(res);
|
|
87
92
|
}
|
|
88
93
|
logInfo(`Mapped to ${byService.size} distinct services`);
|
|
@@ -92,10 +97,13 @@ export async function runSyncAws(teamId, options = {}) {
|
|
|
92
97
|
for (const [serviceName, svcResources] of byService) {
|
|
93
98
|
const teamTag = svcResources[0]?.Tags.find((t) => t.Key === 'Team')?.Value;
|
|
94
99
|
// Tier signals
|
|
95
|
-
const hasMultiAz = svcResources.some((r) => r.ResourceARN.includes(':rds:') ||
|
|
100
|
+
const hasMultiAz = svcResources.some((r) => r.ResourceARN.includes(':rds:') ||
|
|
101
|
+
r.ResourceARN.includes(':elasticache:'));
|
|
96
102
|
const hasLambda = svcResources.some((r) => r.ResourceARN.includes(':lambda:'));
|
|
97
103
|
const hasEcs = svcResources.some((r) => r.ResourceARN.includes(':ecs:'));
|
|
98
|
-
const tier = hasMultiAz || (hasEcs && svcResources.length > 3)
|
|
104
|
+
const tier = hasMultiAz || (hasEcs && svcResources.length > 3)
|
|
105
|
+
? 'critical'
|
|
106
|
+
: 'standard';
|
|
99
107
|
const kind = hasLambda && !hasEcs ? 'job' : 'service';
|
|
100
108
|
if (verbose) {
|
|
101
109
|
logInfo(` ${serviceName}: ${svcResources.length} resources, tier=${tier}`);
|
|
@@ -109,16 +117,36 @@ export async function runSyncAws(teamId, options = {}) {
|
|
|
109
117
|
source: 'aws',
|
|
110
118
|
source_ref: `aws:${region}:${serviceName}`,
|
|
111
119
|
field_sources: {
|
|
112
|
-
name: {
|
|
113
|
-
|
|
114
|
-
|
|
120
|
+
name: {
|
|
121
|
+
value: serviceName,
|
|
122
|
+
confidence: 1.0,
|
|
123
|
+
source: 'aws_tag',
|
|
124
|
+
source_detail: 'Tag:Service',
|
|
125
|
+
},
|
|
126
|
+
tier: {
|
|
127
|
+
value: tier,
|
|
128
|
+
confidence: hasMultiAz ? 0.8 : 0.5,
|
|
129
|
+
source: 'aws_resource_analysis',
|
|
130
|
+
},
|
|
131
|
+
...(teamTag
|
|
132
|
+
? {
|
|
133
|
+
owner: {
|
|
134
|
+
value: teamTag,
|
|
135
|
+
confidence: 0.8,
|
|
136
|
+
source: 'aws_tag',
|
|
137
|
+
source_detail: 'Tag:Team',
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
: {}),
|
|
115
141
|
},
|
|
116
142
|
needs_review: false,
|
|
117
143
|
}));
|
|
118
|
-
if (result.action === 'created')
|
|
144
|
+
if (result.action === 'created') {
|
|
119
145
|
created++;
|
|
120
|
-
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
121
148
|
updated++;
|
|
149
|
+
}
|
|
122
150
|
// Add resource components
|
|
123
151
|
for (const res of svcResources) {
|
|
124
152
|
const arnParts = res.ResourceARN.split(':');
|
|
@@ -164,11 +192,13 @@ export async function runSyncAws(teamId, options = {}) {
|
|
|
164
192
|
for (const period of parsed.ResultsByTime) {
|
|
165
193
|
for (const group of period.Groups) {
|
|
166
194
|
const tagValue = group.Keys[0]?.replace('Service$', '') ?? '';
|
|
167
|
-
if (!tagValue || tagValue === '')
|
|
195
|
+
if (!tagValue || tagValue === '') {
|
|
168
196
|
continue;
|
|
197
|
+
}
|
|
169
198
|
const cost = parseFloat(group.Metrics.BlendedCost.Amount);
|
|
170
|
-
if (cost <= 0)
|
|
199
|
+
if (cost <= 0) {
|
|
171
200
|
continue;
|
|
201
|
+
}
|
|
172
202
|
if (verbose) {
|
|
173
203
|
logInfo(` ${tagValue}: $${cost.toFixed(2)}/month`);
|
|
174
204
|
}
|
|
@@ -11,40 +11,54 @@
|
|
|
11
11
|
* 3. Upsert via MCP endpoints (services.upsert_draft, services.add_component)
|
|
12
12
|
*/
|
|
13
13
|
import { callMcpEndpoint } from '../../api/mcp-client.js';
|
|
14
|
-
import { logError, logInfo, logSuccess, logWarning } from '../../utils/logger.js';
|
|
14
|
+
import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
|
|
15
15
|
function readDatadogEnv() {
|
|
16
16
|
const apiKey = process.env.DD_API_KEY ?? '';
|
|
17
17
|
const appKey = process.env.DD_APP_KEY ?? '';
|
|
18
18
|
const site = process.env.DD_SITE ?? 'datadoghq.com';
|
|
19
19
|
const missing = [];
|
|
20
|
-
if (!apiKey)
|
|
20
|
+
if (!apiKey) {
|
|
21
21
|
missing.push('DD_API_KEY');
|
|
22
|
-
|
|
22
|
+
}
|
|
23
|
+
if (!appKey) {
|
|
23
24
|
missing.push('DD_APP_KEY');
|
|
24
|
-
|
|
25
|
+
}
|
|
26
|
+
if (missing.length > 0) {
|
|
25
27
|
return { ok: false, missing };
|
|
28
|
+
}
|
|
26
29
|
return { ok: true, env: { apiKey, appKey, site } };
|
|
27
30
|
}
|
|
28
31
|
function mapTier(ddTier) {
|
|
29
|
-
if (!ddTier)
|
|
32
|
+
if (!ddTier) {
|
|
30
33
|
return 'standard';
|
|
34
|
+
}
|
|
31
35
|
const lower = ddTier.toLowerCase();
|
|
32
|
-
if (lower.includes('1') ||
|
|
36
|
+
if (lower.includes('1') ||
|
|
37
|
+
lower.includes('critical') ||
|
|
38
|
+
lower.includes('high')) {
|
|
33
39
|
return 'critical';
|
|
34
|
-
|
|
40
|
+
}
|
|
41
|
+
if (lower.includes('3') ||
|
|
42
|
+
lower.includes('low') ||
|
|
43
|
+
lower.includes('experiment')) {
|
|
35
44
|
return 'experimental';
|
|
45
|
+
}
|
|
36
46
|
return 'standard';
|
|
37
47
|
}
|
|
38
48
|
function mapKind(ddType) {
|
|
39
|
-
if (!ddType)
|
|
49
|
+
if (!ddType) {
|
|
40
50
|
return 'service';
|
|
51
|
+
}
|
|
41
52
|
const lower = ddType.toLowerCase();
|
|
42
|
-
if (lower === 'web' || lower === 'website' || lower === 'frontend')
|
|
53
|
+
if (lower === 'web' || lower === 'website' || lower === 'frontend') {
|
|
43
54
|
return 'website';
|
|
44
|
-
|
|
55
|
+
}
|
|
56
|
+
if (lower === 'library' || lower === 'lib') {
|
|
45
57
|
return 'library';
|
|
46
|
-
|
|
58
|
+
}
|
|
59
|
+
if (lower === 'job' || lower === 'worker' || lower === 'cron') {
|
|
47
60
|
return 'job';
|
|
61
|
+
}
|
|
48
62
|
return 'service';
|
|
49
63
|
}
|
|
50
64
|
export async function runSyncDatadog(teamId, options = {}) {
|
|
@@ -97,8 +111,9 @@ export async function runSyncDatadog(teamId, options = {}) {
|
|
|
97
111
|
skipped++;
|
|
98
112
|
continue;
|
|
99
113
|
}
|
|
100
|
-
if (verbose)
|
|
114
|
+
if (verbose) {
|
|
101
115
|
logInfo(`Processing: ${name}`);
|
|
116
|
+
}
|
|
102
117
|
try {
|
|
103
118
|
// Upsert service
|
|
104
119
|
const result = (await callMcpEndpoint('services.upsert_draft', {
|
|
@@ -114,21 +129,40 @@ export async function runSyncDatadog(teamId, options = {}) {
|
|
|
114
129
|
source_ref: `datadog:${name}`,
|
|
115
130
|
field_sources: {
|
|
116
131
|
name: { value: name, confidence: 1.0, source: 'datadog' },
|
|
117
|
-
tier: {
|
|
118
|
-
|
|
119
|
-
|
|
132
|
+
tier: {
|
|
133
|
+
value: mapTier(schema.tier),
|
|
134
|
+
confidence: 0.9,
|
|
135
|
+
source: 'datadog',
|
|
136
|
+
},
|
|
137
|
+
language: {
|
|
138
|
+
value: schema.languages?.[0],
|
|
139
|
+
confidence: 1.0,
|
|
140
|
+
source: 'datadog',
|
|
141
|
+
},
|
|
142
|
+
...(schema.team
|
|
143
|
+
? {
|
|
144
|
+
owner: {
|
|
145
|
+
value: schema.team,
|
|
146
|
+
confidence: 0.9,
|
|
147
|
+
source: 'datadog',
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
: {}),
|
|
120
151
|
},
|
|
121
152
|
needs_review: false,
|
|
122
153
|
}));
|
|
123
154
|
const serviceId = result.service.id;
|
|
124
|
-
if (result.action === 'created')
|
|
155
|
+
if (result.action === 'created') {
|
|
125
156
|
created++;
|
|
126
|
-
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
127
159
|
updated++;
|
|
160
|
+
}
|
|
128
161
|
// Add repo components
|
|
129
162
|
for (const repo of schema.repos ?? []) {
|
|
130
|
-
if (!repo.url)
|
|
163
|
+
if (!repo.url) {
|
|
131
164
|
continue;
|
|
165
|
+
}
|
|
132
166
|
const repoName = repo.url
|
|
133
167
|
.replace(/^https?:\/\/github\.com\//, '')
|
|
134
168
|
.replace(/\.git$/, '');
|
|
@@ -143,14 +177,16 @@ export async function runSyncDatadog(teamId, options = {}) {
|
|
|
143
177
|
componentCount++;
|
|
144
178
|
}
|
|
145
179
|
catch {
|
|
146
|
-
if (verbose)
|
|
180
|
+
if (verbose) {
|
|
147
181
|
logWarning(` Failed to add repo: ${repo.url}`);
|
|
182
|
+
}
|
|
148
183
|
}
|
|
149
184
|
}
|
|
150
185
|
// Add link components (dashboards, runbooks, etc.)
|
|
151
186
|
for (const link of schema.links ?? []) {
|
|
152
|
-
if (!link.url)
|
|
187
|
+
if (!link.url) {
|
|
153
188
|
continue;
|
|
189
|
+
}
|
|
154
190
|
const kind = link.type === 'dashboard' || link.type === 'grafana'
|
|
155
191
|
? 'dashboard'
|
|
156
192
|
: link.type === 'runbook' || link.type === 'doc'
|
|
@@ -169,8 +205,9 @@ export async function runSyncDatadog(teamId, options = {}) {
|
|
|
169
205
|
componentCount++;
|
|
170
206
|
}
|
|
171
207
|
catch {
|
|
172
|
-
if (verbose)
|
|
208
|
+
if (verbose) {
|
|
173
209
|
logWarning(` Failed to add link: ${link.url}`);
|
|
210
|
+
}
|
|
174
211
|
}
|
|
175
212
|
}
|
|
176
213
|
// Add Slack channel
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Reads the team's configured github_org, fetches all repos from that org
|
|
5
5
|
* via the local `gh` CLI, and upserts a repositories row for each repo.
|
|
6
6
|
*/
|
|
7
|
-
import { getSupabase, hasSupabaseSession } from '../../supabase/client.js';
|
|
8
7
|
import { syncOrgRepos } from '../../phases/sync-org-repos/index.js';
|
|
8
|
+
import { getSupabase, hasSupabaseSession } from '../../supabase/client.js';
|
|
9
9
|
import { logError, logInfo, logSuccess } from '../../utils/logger.js';
|
|
10
10
|
const ORG_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
|
|
11
11
|
export async function runSyncOrgRepos(teamId, options = {}) {
|
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
* about module structure, variable passing, resource tagging, etc.
|
|
11
11
|
*/
|
|
12
12
|
import { execSync } from 'child_process';
|
|
13
|
-
import { mkdtempSync,
|
|
13
|
+
import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'fs';
|
|
14
14
|
import { tmpdir } from 'os';
|
|
15
15
|
import { join, relative } from 'path';
|
|
16
16
|
import { callMcpEndpoint } from '../../api/mcp-client.js';
|
|
17
|
-
import { logError, logInfo, logSuccess, logWarning } from '../../utils/logger.js';
|
|
17
|
+
import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
|
|
18
18
|
export async function runSyncTerraform(teamId, options = {}) {
|
|
19
19
|
const { verbose } = options;
|
|
20
20
|
logInfo(`Starting Terraform sync for team ${teamId}`);
|
|
@@ -57,8 +57,9 @@ export async function runSyncTerraform(teamId, options = {}) {
|
|
|
57
57
|
function walk(dir) {
|
|
58
58
|
try {
|
|
59
59
|
for (const entry of readdirSync(dir)) {
|
|
60
|
-
if (entry.startsWith('.') || entry === 'node_modules')
|
|
60
|
+
if (entry.startsWith('.') || entry === 'node_modules') {
|
|
61
61
|
continue;
|
|
62
|
+
}
|
|
62
63
|
const full = join(dir, entry);
|
|
63
64
|
try {
|
|
64
65
|
const stat = statSync(full);
|
|
@@ -100,8 +101,9 @@ export async function runSyncTerraform(teamId, options = {}) {
|
|
|
100
101
|
moduleFiles.get(moduleDir).push({ path: relPath, content });
|
|
101
102
|
}
|
|
102
103
|
catch {
|
|
103
|
-
if (verbose)
|
|
104
|
+
if (verbose) {
|
|
104
105
|
logWarning(`Could not read ${relPath}`);
|
|
106
|
+
}
|
|
105
107
|
}
|
|
106
108
|
}
|
|
107
109
|
logInfo(`Found ${moduleFiles.size} module directories`);
|
|
@@ -111,13 +113,14 @@ export async function runSyncTerraform(teamId, options = {}) {
|
|
|
111
113
|
let components = 0;
|
|
112
114
|
for (const [moduleDir, files] of moduleFiles) {
|
|
113
115
|
// Skip root-level files that are just backend/provider config
|
|
114
|
-
if (moduleDir === '.' &&
|
|
116
|
+
if (moduleDir === '.' &&
|
|
117
|
+
files.every((f) => /^(backend|provider|versions|terraform)\b/.test(f.path))) {
|
|
115
118
|
continue;
|
|
116
119
|
}
|
|
117
120
|
// Extract service name from module directory
|
|
118
121
|
const moduleName = moduleDir === '.'
|
|
119
|
-
? repoFullName?.split('/')[1] ?? 'root'
|
|
120
|
-
: moduleDir.split('/').pop() ?? moduleDir;
|
|
122
|
+
? (repoFullName?.split('/')[1] ?? 'root')
|
|
123
|
+
: (moduleDir.split('/').pop() ?? moduleDir);
|
|
121
124
|
// Extract resource ARNs and types from .tf content
|
|
122
125
|
const resources = [];
|
|
123
126
|
const tags = {};
|
|
@@ -137,18 +140,22 @@ export async function runSyncTerraform(teamId, options = {}) {
|
|
|
137
140
|
tags.Team = m[1];
|
|
138
141
|
}
|
|
139
142
|
// Tier signals
|
|
140
|
-
if (/multi_az\s*=\s*true/i.test(file.content))
|
|
143
|
+
if (/multi_az\s*=\s*true/i.test(file.content)) {
|
|
141
144
|
hasMultiAz = true;
|
|
142
|
-
|
|
145
|
+
}
|
|
146
|
+
if (/aws_appautoscaling|autoscaling_group/i.test(file.content)) {
|
|
143
147
|
hasAutoscaling = true;
|
|
148
|
+
}
|
|
144
149
|
// Module references (dependencies)
|
|
145
150
|
for (const m of file.content.matchAll(/module\.([a-zA-Z0-9_-]+)\./g)) {
|
|
146
|
-
if (!moduleRefs.includes(m[1]))
|
|
151
|
+
if (!moduleRefs.includes(m[1])) {
|
|
147
152
|
moduleRefs.push(m[1]);
|
|
153
|
+
}
|
|
148
154
|
}
|
|
149
155
|
}
|
|
150
|
-
if (resources.length === 0)
|
|
156
|
+
if (resources.length === 0) {
|
|
151
157
|
continue;
|
|
158
|
+
}
|
|
152
159
|
const serviceName = tags.Service ?? moduleName;
|
|
153
160
|
const tier = hasMultiAz || hasAutoscaling ? 'critical' : 'standard';
|
|
154
161
|
if (verbose) {
|
|
@@ -163,16 +170,35 @@ export async function runSyncTerraform(teamId, options = {}) {
|
|
|
163
170
|
source: 'terraform',
|
|
164
171
|
source_ref: `terraform:${repoFullName ?? localDir}:${moduleDir}`,
|
|
165
172
|
field_sources: {
|
|
166
|
-
name: {
|
|
167
|
-
|
|
168
|
-
|
|
173
|
+
name: {
|
|
174
|
+
value: serviceName,
|
|
175
|
+
confidence: tags.Service ? 1.0 : 0.7,
|
|
176
|
+
source: 'terraform',
|
|
177
|
+
source_detail: moduleDir,
|
|
178
|
+
},
|
|
179
|
+
tier: {
|
|
180
|
+
value: tier,
|
|
181
|
+
confidence: hasMultiAz ? 0.8 : 0.5,
|
|
182
|
+
source: 'terraform',
|
|
183
|
+
},
|
|
184
|
+
...(tags.Team
|
|
185
|
+
? {
|
|
186
|
+
owner: {
|
|
187
|
+
value: tags.Team,
|
|
188
|
+
confidence: 0.8,
|
|
189
|
+
source: 'terraform_tag',
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
: {}),
|
|
169
193
|
},
|
|
170
194
|
needs_review: !tags.Service,
|
|
171
195
|
}));
|
|
172
|
-
if (result.action === 'created')
|
|
196
|
+
if (result.action === 'created') {
|
|
173
197
|
created++;
|
|
174
|
-
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
175
200
|
updated++;
|
|
201
|
+
}
|
|
176
202
|
// Add AWS resource components
|
|
177
203
|
for (const res of resources) {
|
|
178
204
|
try {
|
|
@@ -181,7 +207,11 @@ export async function runSyncTerraform(teamId, options = {}) {
|
|
|
181
207
|
kind: 'aws_resource',
|
|
182
208
|
provider: 'terraform',
|
|
183
209
|
external_id: `${res.type}.${res.name}`,
|
|
184
|
-
metadata: {
|
|
210
|
+
metadata: {
|
|
211
|
+
resource_type: res.type,
|
|
212
|
+
resource_name: res.name,
|
|
213
|
+
module: moduleDir,
|
|
214
|
+
},
|
|
185
215
|
});
|
|
186
216
|
components++;
|
|
187
217
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* System prompts for the product chat AI processor.
|
|
3
3
|
*/
|
|
4
|
-
export declare const PRODUCT_CHAT_RESPONSE_PROMPT = "You are an AI assistant embedded in Edsger, a software development platform. You are helping a team manage a product at the product level.\n\n## Your Capabilities\nYou can see the product's current state, all issues (with statuses), and team members. You have tools to:\n- List issues with status filtering\n- Create new issues for the product\n- Get detailed issue information (drill down into any issue)\n- Create tasks for team members (human) or AI\n- Look up product team members by name\n- Send follow-up messages and present options to the user\n\n## How to Respond\n1. **Understand the intent** \u2014 Is this a question about product state, a request to create something, or coordination work?\n2. **Take action if needed** \u2014 Use the appropriate tools to make changes\n3. **Respond concisely** \u2014 Summarize what you understood and what you did\n4. **Ask for clarification** \u2014 If the message is ambiguous, use provide_options to present choices\n\n## Communication Style\n- Respond in the same language the user writes in\n- Be concise but thorough \u2014 no filler text\n- Reference specific issues, statuses, and team members by name\n- When making changes, always explain what you changed and why\n\n## Product-Level Scope\nUnlike issue chat (which focuses on a single issue's lifecycle), you operate at the product level:\n- Answer cross-issue questions (e.g., \"which issues are blocked?\", \"what's our progress?\")\n- Help with product planning and prioritization\n- Create new issues when the user describes new work\n- Coordinate team work by creating and assigning tasks\n- Provide product-wide insights and summaries\n\n## Task Creation\nWhen the user asks to notify someone, assign a review, or request action from a team member:\n1. Use list_product_members to find the person by name\n2. Use create_task with executor=\"human\", the resolved user ID, and the correct action_url\n3. Confirm in chat what you created and who it's assigned to\n\nWhen the user describes work for AI to do:\n1. Use create_task with executor=\"ai\" \u2014 the task worker will pick it up automatically\n\n### Action URL Patterns\nAlways set action_url to link to the most relevant page:\n- Product page: `/products/{product_id}`\n- Issue details: `/products/{product_id}/issues/{issue_id}`\n- Issue tab: `/products/{product_id}/issues/{issue_id}?tab={tab}`\n\n## Important Rules\n- Never make destructive changes without confirmation\n- For ambiguous requests, present options rather than guessing\n- If you can't do something, explain why clearly\n- When creating tasks for people, always confirm the person's identity if the name is ambiguous\n";
|
|
4
|
+
export declare const PRODUCT_CHAT_RESPONSE_PROMPT = "You are an AI assistant embedded in Edsger, a software development platform. You are helping a team manage a product at the product level.\n\n## Your Capabilities\nYou can see the product's current state, all issues (with statuses), and team members. You have tools to:\n- List issues with status filtering\n- Create new issues for the product\n- Get detailed issue information (drill down into any issue)\n- Create tasks for team members (human) or AI\n- Look up product team members by name\n- Send follow-up messages and present options to the user\n- Launch a code analysis of the product's repo (cli_find_bugs / cli_find_smells / cli_find_architecture / cli_find_features) and check a run's progress (get_cli_run)\n\n## Analyzing the code\nYou cannot read the source directly, but you can launch an audit that does. When the user asks about code quality, what to optimize / refactor / improve, bugs, dead code, performance, or architecture, do NOT just speculate from issue titles \u2014 launch the matching analysis:\n- \"what should we optimize / refactor / improve\", code smells, dead code, perf, type-safety \u2192 cli_find_smells\n- bugs / correctness audit \u2192 cli_find_bugs\n- architecture / coupling / layering / cycles \u2192 cli_find_architecture\n- feature opportunities from feedback + code \u2192 cli_find_features\n\nThese run in the background and FILE EACH FINDING AS AN ISSUE \u2014 they do not return results inline. So: launch the run, tell the user it's running and that findings will show up as new issues, and offer to check progress later with get_cli_run (or re-list issues). Don't claim to have analyzed the code yourself.\n\n## How to Respond\n1. **Understand the intent** \u2014 Is this a question about product state, a request to create something, or coordination work?\n2. **Take action if needed** \u2014 Use the appropriate tools to make changes\n3. **Respond concisely** \u2014 Summarize what you understood and what you did\n4. **Ask for clarification** \u2014 If the message is ambiguous, use provide_options to present choices\n\n## Communication Style\n- Respond in the same language the user writes in\n- Be concise but thorough \u2014 no filler text\n- Reference specific issues, statuses, and team members by name\n- When making changes, always explain what you changed and why\n\n## Product-Level Scope\nUnlike issue chat (which focuses on a single issue's lifecycle), you operate at the product level:\n- Answer cross-issue questions (e.g., \"which issues are blocked?\", \"what's our progress?\")\n- Help with product planning and prioritization\n- Create new issues when the user describes new work\n- Coordinate team work by creating and assigning tasks\n- Provide product-wide insights and summaries\n\n## Task Creation\nWhen the user asks to notify someone, assign a review, or request action from a team member:\n1. Use list_product_members to find the person by name\n2. Use create_task with executor=\"human\", the resolved user ID, and the correct action_url\n3. Confirm in chat what you created and who it's assigned to\n\nWhen the user describes work for AI to do:\n1. Use create_task with executor=\"ai\" \u2014 the task worker will pick it up automatically\n\n### Action URL Patterns\nAlways set action_url to link to the most relevant page:\n- Product page: `/products/{product_id}`\n- Issue details: `/products/{product_id}/issues/{issue_id}`\n- Issue tab: `/products/{product_id}/issues/{issue_id}?tab={tab}`\n\n## Important Rules\n- Never make destructive changes without confirmation\n- For ambiguous requests, present options rather than guessing\n- If you can't do something, explain why clearly\n- When creating tasks for people, always confirm the person's identity if the name is ambiguous\n";
|
|
@@ -11,6 +11,16 @@ You can see the product's current state, all issues (with statuses), and team me
|
|
|
11
11
|
- Create tasks for team members (human) or AI
|
|
12
12
|
- Look up product team members by name
|
|
13
13
|
- Send follow-up messages and present options to the user
|
|
14
|
+
- Launch a code analysis of the product's repo (cli_find_bugs / cli_find_smells / cli_find_architecture / cli_find_features) and check a run's progress (get_cli_run)
|
|
15
|
+
|
|
16
|
+
## Analyzing the code
|
|
17
|
+
You cannot read the source directly, but you can launch an audit that does. When the user asks about code quality, what to optimize / refactor / improve, bugs, dead code, performance, or architecture, do NOT just speculate from issue titles — launch the matching analysis:
|
|
18
|
+
- "what should we optimize / refactor / improve", code smells, dead code, perf, type-safety → cli_find_smells
|
|
19
|
+
- bugs / correctness audit → cli_find_bugs
|
|
20
|
+
- architecture / coupling / layering / cycles → cli_find_architecture
|
|
21
|
+
- feature opportunities from feedback + code → cli_find_features
|
|
22
|
+
|
|
23
|
+
These run in the background and FILE EACH FINDING AS AN ISSUE — they do not return results inline. So: launch the run, tell the user it's running and that findings will show up as new issues, and offer to check progress later with get_cli_run (or re-list issues). Don't claim to have analyzed the code yourself.
|
|
14
24
|
|
|
15
25
|
## How to Respond
|
|
16
26
|
1. **Understand the intent** — Is this a question about product state, a request to create something, or coordination work?
|
|
@@ -31,6 +31,19 @@ export interface CloneFlowReposFailure {
|
|
|
31
31
|
ok: false;
|
|
32
32
|
message: string;
|
|
33
33
|
}
|
|
34
|
+
export declare function safeDirName(fullName: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the repositories a flow targets (by id, preserving the stored
|
|
37
|
+
* order), falling back to the product's primary repo.
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveTargetRepos(productId: string, repositoryIds: string[], fallback: {
|
|
40
|
+
owner: string;
|
|
41
|
+
repo: string;
|
|
42
|
+
}): Promise<{
|
|
43
|
+
fullName: string;
|
|
44
|
+
owner: string;
|
|
45
|
+
repo: string;
|
|
46
|
+
}[]>;
|
|
34
47
|
export declare function cloneFlowRepos(opts: {
|
|
35
48
|
productId: string;
|
|
36
49
|
repositoryIds: string[];
|
|
@@ -17,14 +17,14 @@ import { getGitHubConfigByProduct } 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';
|
|
20
|
-
function safeDirName(fullName) {
|
|
20
|
+
export function safeDirName(fullName) {
|
|
21
21
|
return fullName.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
22
22
|
}
|
|
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
26
|
*/
|
|
27
|
-
async function resolveTargetRepos(productId, repositoryIds, fallback) {
|
|
27
|
+
export async function resolveTargetRepos(productId, repositoryIds, fallback) {
|
|
28
28
|
if (repositoryIds.length === 0) {
|
|
29
29
|
return [
|
|
30
30
|
{
|
|
@@ -39,10 +39,7 @@ async function resolveTargetRepos(productId, repositoryIds, fallback) {
|
|
|
39
39
|
.from('repositories')
|
|
40
40
|
.select('id, full_name')
|
|
41
41
|
.in('id', repositoryIds);
|
|
42
|
-
const byId = new Map((
|
|
43
|
-
r.id,
|
|
44
|
-
r.full_name,
|
|
45
|
-
]));
|
|
42
|
+
const byId = new Map((data ?? []).map((r) => [r.id, r.full_name]));
|
|
46
43
|
// Preserve the caller's order (flows.repository_ids is ordered).
|
|
47
44
|
const resolved = [];
|
|
48
45
|
for (const id of repositoryIds) {
|
|
@@ -104,7 +101,10 @@ export async function cloneFlowRepos(opts) {
|
|
|
104
101
|
}
|
|
105
102
|
}
|
|
106
103
|
if (repos.length === 0) {
|
|
107
|
-
return {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
message: 'Failed to clone any of the selected repositories.',
|
|
107
|
+
};
|
|
108
108
|
}
|
|
109
109
|
if (repos.length > 1) {
|
|
110
110
|
logInfo(`Cloned ${repos.length} repos for ${workspaceKey}: ${repos.map((r) => r.fullName).join(', ')}`);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session workspace — clones a product's configured repositories into a single
|
|
3
|
+
* local "session directory", one subdirectory per repo.
|
|
4
|
+
*
|
|
5
|
+
* A chat session (an `ai_assistant` channel) is bound to a product, and a
|
|
6
|
+
* product can span several repositories. For the session agent's read-only
|
|
7
|
+
* tools (Read / Grep / Glob) to inspect the real code, we clone every in-scope
|
|
8
|
+
* repo into:
|
|
9
|
+
*
|
|
10
|
+
* <workspace>/sessions/<channelId>/<owner>_<repo>/
|
|
11
|
+
*
|
|
12
|
+
* and point the agent's cwd at the per-session directory. The clones are reused
|
|
13
|
+
* (and fetched) across turns of the same session rather than re-cloned each
|
|
14
|
+
* time, so follow-up turns are fast and always see the latest code.
|
|
15
|
+
*
|
|
16
|
+
* Repo resolution and the secure-token clone are shared with flow generation
|
|
17
|
+
* (`phases/flow-shared/clone-repos.ts`).
|
|
18
|
+
*/
|
|
19
|
+
import { type ClonedRepo } from '../phases/flow-shared/clone-repos.js';
|
|
20
|
+
export interface SessionWorkspace {
|
|
21
|
+
/** Directory holding every cloned repo as a subdirectory; the agent's cwd. */
|
|
22
|
+
sessionDir: string;
|
|
23
|
+
/** The repos that were successfully cloned/refreshed for this session. */
|
|
24
|
+
repos: ClonedRepo[];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Clone (or refresh) every repository the session's product is scoped to into a
|
|
28
|
+
* single local session directory. Returns the directory to use as the agent's
|
|
29
|
+
* cwd plus the cloned repos, or `null` when no repo could be made available
|
|
30
|
+
* (no GitHub config, or every clone failed) so the caller can still run the
|
|
31
|
+
* agent without a codebase.
|
|
32
|
+
*/
|
|
33
|
+
export declare function cloneSessionRepos(opts: {
|
|
34
|
+
channelId: string;
|
|
35
|
+
productId: string;
|
|
36
|
+
repositoryIds: string[];
|
|
37
|
+
verbose?: boolean;
|
|
38
|
+
}): Promise<SessionWorkspace | null>;
|
|
39
|
+
/**
|
|
40
|
+
* Build a note for the agent's system prompt describing where each repo is
|
|
41
|
+
* checked out, so it points Read / Grep / Glob at the right subdirectory.
|
|
42
|
+
*/
|
|
43
|
+
export declare function describeSessionRepos(repos: ClonedRepo[]): string;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session workspace — clones a product's configured repositories into a single
|
|
3
|
+
* local "session directory", one subdirectory per repo.
|
|
4
|
+
*
|
|
5
|
+
* A chat session (an `ai_assistant` channel) is bound to a product, and a
|
|
6
|
+
* product can span several repositories. For the session agent's read-only
|
|
7
|
+
* tools (Read / Grep / Glob) to inspect the real code, we clone every in-scope
|
|
8
|
+
* repo into:
|
|
9
|
+
*
|
|
10
|
+
* <workspace>/sessions/<channelId>/<owner>_<repo>/
|
|
11
|
+
*
|
|
12
|
+
* and point the agent's cwd at the per-session directory. The clones are reused
|
|
13
|
+
* (and fetched) across turns of the same session rather than re-cloned each
|
|
14
|
+
* time, so follow-up turns are fast and always see the latest code.
|
|
15
|
+
*
|
|
16
|
+
* Repo resolution and the secure-token clone are shared with flow generation
|
|
17
|
+
* (`phases/flow-shared/clone-repos.ts`).
|
|
18
|
+
*/
|
|
19
|
+
import { mkdirSync } from 'fs';
|
|
20
|
+
import { getGitHubConfigByProduct } from '../api/github.js';
|
|
21
|
+
import { resolveTargetRepos, safeDirName, } from '../phases/flow-shared/clone-repos.js';
|
|
22
|
+
import { logInfo, logWarning } from '../utils/logger.js';
|
|
23
|
+
import { cloneIssueRepo, ensureWorkspaceDir, getIssueRepoPath, } from './workspace-manager.js';
|
|
24
|
+
const SESSIONS_DIR_NAME = 'sessions';
|
|
25
|
+
/**
|
|
26
|
+
* Clone (or refresh) every repository the session's product is scoped to into a
|
|
27
|
+
* single local session directory. Returns the directory to use as the agent's
|
|
28
|
+
* cwd plus the cloned repos, or `null` when no repo could be made available
|
|
29
|
+
* (no GitHub config, or every clone failed) so the caller can still run the
|
|
30
|
+
* agent without a codebase.
|
|
31
|
+
*/
|
|
32
|
+
export async function cloneSessionRepos(opts) {
|
|
33
|
+
const { channelId, productId, repositoryIds, verbose } = opts;
|
|
34
|
+
const gh = await getGitHubConfigByProduct(productId, verbose);
|
|
35
|
+
if (!gh.configured || !gh.token || !gh.owner || !gh.repo) {
|
|
36
|
+
logWarning(gh.message ||
|
|
37
|
+
'No GitHub repository configured for this product; the session agent will run without a codebase.');
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
// Resolve the in-scope repos (ordered), falling back to the product's
|
|
41
|
+
// primary repo when none were explicitly selected.
|
|
42
|
+
const targets = await resolveTargetRepos(productId, repositoryIds, {
|
|
43
|
+
owner: gh.owner,
|
|
44
|
+
repo: gh.repo,
|
|
45
|
+
});
|
|
46
|
+
const workspaceRoot = ensureWorkspaceDir();
|
|
47
|
+
const sessionDir = getIssueRepoPath(workspaceRoot, `${SESSIONS_DIR_NAME}/${safeDirName(channelId)}`);
|
|
48
|
+
// Ensure the per-session parent exists before cloning into subdirectories.
|
|
49
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
50
|
+
const repos = [];
|
|
51
|
+
for (const target of targets) {
|
|
52
|
+
try {
|
|
53
|
+
// The product-level token (installation or user PAT/OAuth) is reused for
|
|
54
|
+
// every repo; if it can't access one, that clone fails and we skip it
|
|
55
|
+
// rather than aborting the whole session.
|
|
56
|
+
const { repoPath } = cloneIssueRepo(sessionDir, safeDirName(target.fullName), target.owner, target.repo, gh.token);
|
|
57
|
+
repos.push({
|
|
58
|
+
fullName: target.fullName,
|
|
59
|
+
owner: target.owner,
|
|
60
|
+
repo: target.repo,
|
|
61
|
+
dir: repoPath,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
logWarning(`Skipping ${target.fullName}: clone failed (${err instanceof Error ? err.message : String(err)})`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (repos.length === 0) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
logInfo(`Session ${channelId}: ${repos.length} repo(s) ready in ${sessionDir}`);
|
|
72
|
+
return { sessionDir, repos };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Build a note for the agent's system prompt describing where each repo is
|
|
76
|
+
* checked out, so it points Read / Grep / Glob at the right subdirectory.
|
|
77
|
+
*/
|
|
78
|
+
export function describeSessionRepos(repos) {
|
|
79
|
+
if (repos.length === 0) {
|
|
80
|
+
return '';
|
|
81
|
+
}
|
|
82
|
+
const list = repos.map((r) => `- ${r.fullName} → subdirectory \`${safeDirName(r.fullName)}/\``);
|
|
83
|
+
return [
|
|
84
|
+
repos.length === 1
|
|
85
|
+
? 'The product code is checked out in your working directory:'
|
|
86
|
+
: `This product spans ${repos.length} repositories, each checked out as a subdirectory of your working directory:`,
|
|
87
|
+
...list,
|
|
88
|
+
'Use Read / Grep / Glob against these paths to inspect the actual code before answering.',
|
|
89
|
+
].join('\n');
|
|
90
|
+
}
|
package/eslint.config.mjs
CHANGED
|
@@ -5,6 +5,10 @@ import {
|
|
|
5
5
|
} from '../edsger-lint/eslint/index.mjs'
|
|
6
6
|
|
|
7
7
|
export default [
|
|
8
|
+
// Test-only infra (the node:test→vitest shim) lives outside the src TS
|
|
9
|
+
// project, so skip it for the type-checked lint.
|
|
10
|
+
{ ignores: ['test/**'] },
|
|
11
|
+
|
|
8
12
|
...baseConfig,
|
|
9
13
|
|
|
10
14
|
// Type-checked rules
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "edsger",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.64.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"edsger": "dist/index.js"
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"commander": "^12.0.0",
|
|
51
51
|
"cosmiconfig": "^9.0.0",
|
|
52
52
|
"dotenv": "^16.4.5",
|
|
53
|
-
"edsger-contract": "0.
|
|
54
|
-
"edsger-tools": "0.
|
|
53
|
+
"edsger-contract": "0.7.0",
|
|
54
|
+
"edsger-tools": "0.7.0",
|
|
55
55
|
"gray-matter": "^4.0.3",
|
|
56
56
|
"zod": "^4.0.0"
|
|
57
57
|
},
|
package/vitest.config.ts
CHANGED
|
@@ -1,26 +1,19 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url'
|
|
2
|
+
|
|
1
3
|
import { defineConfig } from 'vitest/config'
|
|
2
4
|
|
|
3
5
|
export default defineConfig({
|
|
4
6
|
test: {
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
include: [
|
|
10
|
-
'src/phases/run-sheet/__tests__/**/*.test.ts',
|
|
11
|
-
'src/phases/code-refine-verification/__tests__/**/*.test.ts',
|
|
12
|
-
'src/phases/find-bugs/__tests__/**/*.test.ts',
|
|
13
|
-
'src/phases/find-features/__tests__/**/*.test.ts',
|
|
14
|
-
'src/phases/find-smells/__tests__/**/*.test.ts',
|
|
15
|
-
'src/phases/sync-github-issues/__tests__/**/*.test.ts',
|
|
16
|
-
'src/phases/sync-sentry-issues/__tests__/**/*.test.ts',
|
|
17
|
-
'src/phases/sync-shared/__tests__/**/*.test.ts',
|
|
18
|
-
'src/phases/screen-flow/__tests__/**/*.test.ts',
|
|
19
|
-
'src/phases/recipes/__tests__/**/*.test.ts',
|
|
20
|
-
'src/types/__tests__/**/*.test.ts',
|
|
21
|
-
'src/commands/find-smells/__tests__/**/*.test.ts',
|
|
22
|
-
],
|
|
7
|
+
// Run every test under src, including the many files written against the
|
|
8
|
+
// node:test API — those used to be silently skipped. The `node:test` alias
|
|
9
|
+
// below lets them run under vitest unchanged.
|
|
10
|
+
include: ['src/**/__tests__/**/*.test.ts'],
|
|
23
11
|
exclude: ['dist/**', 'node_modules/**'],
|
|
24
12
|
environment: 'node',
|
|
13
|
+
alias: {
|
|
14
|
+
'node:test': fileURLToPath(
|
|
15
|
+
new URL('./test/node-test-shim.ts', import.meta.url)
|
|
16
|
+
),
|
|
17
|
+
},
|
|
25
18
|
},
|
|
26
19
|
})
|