@nikitadmitrieff/feedback-chat 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/init.js +158 -0
- package/dist/client/index.d.ts +43 -0
- package/dist/client/index.js +2294 -0
- package/dist/server/index.d.ts +115 -0
- package/dist/server/index.js +548 -0
- package/dist/styles.css +298 -0
- package/package.json +61 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import * as ai from 'ai';
|
|
2
|
+
import { LanguageModel } from 'ai';
|
|
3
|
+
|
|
4
|
+
type FeedbackHandlerConfig = {
|
|
5
|
+
/** Password required for authentication */
|
|
6
|
+
password: string;
|
|
7
|
+
/** AI model to use. Defaults to claude-haiku-4-5-20251001 */
|
|
8
|
+
model?: LanguageModel;
|
|
9
|
+
/** Custom system prompt. If not provided, uses buildDefaultPrompt with projectContext */
|
|
10
|
+
systemPrompt?: string;
|
|
11
|
+
/** Project context passed to buildDefaultPrompt (ignored if systemPrompt is provided) */
|
|
12
|
+
projectContext?: string;
|
|
13
|
+
/** GitHub configuration for issue creation */
|
|
14
|
+
github?: {
|
|
15
|
+
token: string;
|
|
16
|
+
repo: string;
|
|
17
|
+
labels?: string[];
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Creates a Next.js App Router POST handler for the feedback chat.
|
|
22
|
+
* Returns `{ POST }` ready to be exported from a route.ts file.
|
|
23
|
+
*/
|
|
24
|
+
declare function createFeedbackHandler(config: FeedbackHandlerConfig): {
|
|
25
|
+
POST: (req: Request) => Promise<Response>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type StatusHandlerConfig = {
|
|
29
|
+
/** Password required for POST actions */
|
|
30
|
+
password: string;
|
|
31
|
+
/** GitHub configuration. If not provided, reads from GITHUB_TOKEN / GITHUB_REPO env vars */
|
|
32
|
+
github?: {
|
|
33
|
+
token: string;
|
|
34
|
+
repo: string;
|
|
35
|
+
};
|
|
36
|
+
/** Agent URL for checking if the agent is currently running a job */
|
|
37
|
+
agentUrl?: string;
|
|
38
|
+
};
|
|
39
|
+
type Stage = 'created' | 'queued' | 'running' | 'validating' | 'preview_ready' | 'deployed' | 'failed' | 'rejected';
|
|
40
|
+
type StatusResponse = {
|
|
41
|
+
stage: Stage;
|
|
42
|
+
issueNumber: number;
|
|
43
|
+
issueUrl: string;
|
|
44
|
+
failReason?: string;
|
|
45
|
+
previewUrl?: string;
|
|
46
|
+
prNumber?: number;
|
|
47
|
+
prUrl?: string;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Creates Next.js App Router GET and POST handlers for the feedback status endpoint.
|
|
51
|
+
* Returns `{ GET, POST }` ready to be exported from a route.ts file.
|
|
52
|
+
*
|
|
53
|
+
* GET /api/feedback/status?issue=N — returns current pipeline stage
|
|
54
|
+
* POST /api/feedback/status?action=retry|approve|reject|request_changes&issue=N — performs action
|
|
55
|
+
*/
|
|
56
|
+
declare function createStatusHandler(config: StatusHandlerConfig): {
|
|
57
|
+
GET: (req: Request) => Promise<Response>;
|
|
58
|
+
POST: (req: Request) => Promise<Response>;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Default system prompt for the feedback chatbot.
|
|
63
|
+
* Generalized English version of the original French prompt.
|
|
64
|
+
*/
|
|
65
|
+
declare function buildDefaultPrompt(projectContext?: string): string;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Function signature for creating a GitHub issue.
|
|
69
|
+
* Returns the issue URL on success, or null if GitHub is not configured.
|
|
70
|
+
*/
|
|
71
|
+
type GitHubIssueCreator = (params: {
|
|
72
|
+
title: string;
|
|
73
|
+
body: string;
|
|
74
|
+
labels?: string[];
|
|
75
|
+
}) => Promise<string | null>;
|
|
76
|
+
/**
|
|
77
|
+
* Creates the feedback chatbot tool definitions.
|
|
78
|
+
* Uses dependency injection for the GitHub issue creator.
|
|
79
|
+
*/
|
|
80
|
+
declare function createTools(createIssue?: GitHubIssueCreator): {
|
|
81
|
+
present_options: ai.Tool<{
|
|
82
|
+
options: string[];
|
|
83
|
+
}, {
|
|
84
|
+
presented: boolean;
|
|
85
|
+
count: number;
|
|
86
|
+
}>;
|
|
87
|
+
submit_request: ai.Tool<{
|
|
88
|
+
summary: string;
|
|
89
|
+
prompt_type: "simple" | "ralph_loop";
|
|
90
|
+
generated_prompt: string;
|
|
91
|
+
spec_content?: string | undefined;
|
|
92
|
+
visitor_name?: string | undefined;
|
|
93
|
+
}, {
|
|
94
|
+
success: boolean;
|
|
95
|
+
github_issue_url: string;
|
|
96
|
+
message?: undefined;
|
|
97
|
+
} | {
|
|
98
|
+
success: boolean;
|
|
99
|
+
message: string;
|
|
100
|
+
github_issue_url?: undefined;
|
|
101
|
+
}>;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* GitHub issue creator.
|
|
106
|
+
* Reads GITHUB_TOKEN and GITHUB_REPO from environment variables.
|
|
107
|
+
* Returns null gracefully if env vars are missing.
|
|
108
|
+
*/
|
|
109
|
+
declare function createGitHubIssue({ title, body, labels, }: {
|
|
110
|
+
title: string;
|
|
111
|
+
body: string;
|
|
112
|
+
labels?: string[];
|
|
113
|
+
}): Promise<string | null>;
|
|
114
|
+
|
|
115
|
+
export { type FeedbackHandlerConfig, type GitHubIssueCreator, type Stage, type StatusHandlerConfig, type StatusResponse, buildDefaultPrompt, createFeedbackHandler, createGitHubIssue, createStatusHandler, createTools };
|
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
// src/server/handler.ts
|
|
2
|
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
3
|
+
import {
|
|
4
|
+
convertToModelMessages,
|
|
5
|
+
stepCountIs,
|
|
6
|
+
streamText
|
|
7
|
+
} from "ai";
|
|
8
|
+
|
|
9
|
+
// src/server/default-prompt.ts
|
|
10
|
+
function buildDefaultPrompt(projectContext) {
|
|
11
|
+
const contextBlock = projectContext ? `## Project Context
|
|
12
|
+
${projectContext}` : "(No project context provided.)";
|
|
13
|
+
return `You are a product advisor embedded in an application. You speak to non-technical users who have ideas for improving the product. Your role: understand their need, guide them with your knowledge of the product, and turn their idea into a precise request.
|
|
14
|
+
|
|
15
|
+
${contextBlock}
|
|
16
|
+
|
|
17
|
+
## Your Approach
|
|
18
|
+
|
|
19
|
+
You are NOT a form. You are an advisor who knows the product inside out. When someone brings up an idea, you:
|
|
20
|
+
- **Understand the deeper intent** behind the request, not just the words
|
|
21
|
+
- **Propose concrete solutions** building on what already exists in the product
|
|
22
|
+
- **Explain what you envision** so the person can say "yes that's it" or "no, more like this"
|
|
23
|
+
- **Anticipate implications** \u2014 if someone wants to change one feature, you know what else it might affect
|
|
24
|
+
|
|
25
|
+
### How You Guide the Conversation
|
|
26
|
+
|
|
27
|
+
**From the very first message**, rephrase the idea with your own understanding of the product and propose a direction:
|
|
28
|
+
- "Great idea! Currently the product has [description]. What I'd suggest is [concrete proposal]. Does that sound right?"
|
|
29
|
+
- "I see what you mean. We could do this two ways: [option A] or [option B]. Which feels better to you?"
|
|
30
|
+
|
|
31
|
+
**If the idea is vague**, don't ask "can you clarify?" \u2014 instead propose concrete directions.
|
|
32
|
+
|
|
33
|
+
**IMPORTANT \u2014 presenting choices:** When you want to offer options to the user, call the \`present_options\` tool with an array of options. Do NOT list options as numbered text \u2014 always use the tool so the interface displays clickable buttons.
|
|
34
|
+
|
|
35
|
+
**If the idea is clear**, confirm quickly and move to submission without unnecessary questions.
|
|
36
|
+
|
|
37
|
+
### Being Proactive
|
|
38
|
+
|
|
39
|
+
- If the request is small, suggest complementary improvements: "While we're changing [X], should we also [Y]?"
|
|
40
|
+
- Explain why your proposal is good: "I'd suggest [alternative] because [product-related reason]"
|
|
41
|
+
- If you see a potential issue, mention it kindly: "Heads up \u2014 if we do this we'd also need to think about [consequence]"
|
|
42
|
+
- Give visual examples when possible: "Imagine a blue card with a large title and a button below it"
|
|
43
|
+
|
|
44
|
+
## Submission
|
|
45
|
+
|
|
46
|
+
When you agree on the request, summarize in 2-3 simple sentences what will be done, then call submit_request.
|
|
47
|
+
|
|
48
|
+
## Rules
|
|
49
|
+
- Keep a warm and enthusiastic tone (you love when people suggest ideas)
|
|
50
|
+
- Match the user's formality level
|
|
51
|
+
- NEVER use technical jargon \u2014 no "component", "API", "database", "route", "state", "responsive"
|
|
52
|
+
- 2 to 3 exchanges maximum \u2014 if it's clear from the start, submit after 1 exchange
|
|
53
|
+
- Skip steps already covered when the person gives a lot of detail at once
|
|
54
|
+
|
|
55
|
+
## Rules for the Generated Prompt
|
|
56
|
+
- generated_prompt is ALWAYS in English (it's for the development tool)
|
|
57
|
+
- summary should be in the user's language (it's for the human)
|
|
58
|
+
- Small change -> prompt_type: "simple" \u2014 clear description, relevant files, expected outcome
|
|
59
|
+
- Large change (new page, new system) -> prompt_type: "ralph_loop" \u2014 high-level description + spec_content with Goal, numbered Tasks, Acceptance Criteria
|
|
60
|
+
- Always mention specific files to modify when you know them (refer to the project context above)
|
|
61
|
+
- The prompt must be detailed enough for a developer to implement without extra context`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/server/tools.ts
|
|
65
|
+
import { tool } from "ai";
|
|
66
|
+
import { z } from "zod";
|
|
67
|
+
function createTools(createIssue) {
|
|
68
|
+
return {
|
|
69
|
+
present_options: tool({
|
|
70
|
+
description: "Present clickable options to the user for selection. Use this whenever you want to offer choices instead of writing numbered lists. After calling this tool, end your message briefly \u2014 the user will click an option to continue.",
|
|
71
|
+
inputSchema: z.object({
|
|
72
|
+
options: z.array(z.string()).min(2).max(5).describe("The options to present as clickable buttons")
|
|
73
|
+
}),
|
|
74
|
+
execute: async ({ options }) => ({ presented: true, count: options.length })
|
|
75
|
+
}),
|
|
76
|
+
submit_request: tool({
|
|
77
|
+
description: "Submit the finalized feedback request with a generated prompt for Claude Code.",
|
|
78
|
+
inputSchema: z.object({
|
|
79
|
+
summary: z.string().describe("Brief summary of the request"),
|
|
80
|
+
prompt_type: z.enum(["simple", "ralph_loop"]).describe(
|
|
81
|
+
"simple for small changes, ralph_loop for large features"
|
|
82
|
+
),
|
|
83
|
+
generated_prompt: z.string().describe("The generated prompt in English for Claude Code"),
|
|
84
|
+
spec_content: z.string().optional().describe(
|
|
85
|
+
"Markdown spec content for ralph_loop type requests"
|
|
86
|
+
),
|
|
87
|
+
visitor_name: z.string().optional().describe("Name of the person making the request")
|
|
88
|
+
}),
|
|
89
|
+
execute: async (args) => {
|
|
90
|
+
const bodyParts = [
|
|
91
|
+
`## Generated Prompt
|
|
92
|
+
|
|
93
|
+
\`\`\`
|
|
94
|
+
${args.generated_prompt}
|
|
95
|
+
\`\`\``
|
|
96
|
+
];
|
|
97
|
+
if (args.spec_content) {
|
|
98
|
+
bodyParts.push(`## Spec Content
|
|
99
|
+
|
|
100
|
+
${args.spec_content}`);
|
|
101
|
+
}
|
|
102
|
+
bodyParts.push(
|
|
103
|
+
`## Metadata
|
|
104
|
+
|
|
105
|
+
- **Type:** ${args.prompt_type}
|
|
106
|
+
- **Submitted by:** ${args.visitor_name || "Anonymous"}`
|
|
107
|
+
);
|
|
108
|
+
const agentMeta = JSON.stringify({
|
|
109
|
+
prompt_type: args.prompt_type,
|
|
110
|
+
visitor_name: args.visitor_name || "Anonymous"
|
|
111
|
+
});
|
|
112
|
+
bodyParts.push(`<!-- agent-meta: ${agentMeta} -->`);
|
|
113
|
+
const issueBody = bodyParts.join("\n\n");
|
|
114
|
+
if (createIssue) {
|
|
115
|
+
const githubUrl = await createIssue({
|
|
116
|
+
title: `[Feedback] ${args.summary}`,
|
|
117
|
+
body: issueBody,
|
|
118
|
+
labels: ["feedback-bot", "auto-implement"]
|
|
119
|
+
});
|
|
120
|
+
if (githubUrl) {
|
|
121
|
+
return { success: true, github_issue_url: githubUrl };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { success: true, message: "Saved locally" };
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/server/github.ts
|
|
131
|
+
async function createGitHubIssue({
|
|
132
|
+
title,
|
|
133
|
+
body,
|
|
134
|
+
labels = ["feedback-bot"]
|
|
135
|
+
}) {
|
|
136
|
+
const token = process.env.GITHUB_TOKEN;
|
|
137
|
+
const repo = process.env.GITHUB_REPO;
|
|
138
|
+
if (!token || !repo) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const response = await fetch(
|
|
143
|
+
`https://api.github.com/repos/${repo}/issues`,
|
|
144
|
+
{
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: {
|
|
147
|
+
Authorization: `Bearer ${token}`,
|
|
148
|
+
Accept: "application/vnd.github+json",
|
|
149
|
+
"Content-Type": "application/json"
|
|
150
|
+
},
|
|
151
|
+
body: JSON.stringify({ title, body, labels })
|
|
152
|
+
}
|
|
153
|
+
);
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
console.error("GitHub issue creation failed:", response.status);
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
const data = await response.json();
|
|
159
|
+
return data.html_url;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
console.error("GitHub issue creation error:", error);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/server/handler.ts
|
|
167
|
+
function createFeedbackHandler(config) {
|
|
168
|
+
const POST = async (req) => {
|
|
169
|
+
const { messages, password } = await req.json();
|
|
170
|
+
if (password !== config.password) {
|
|
171
|
+
return Response.json({ error: "Invalid password" }, { status: 401 });
|
|
172
|
+
}
|
|
173
|
+
if (!messages.length) {
|
|
174
|
+
return Response.json({ ok: true });
|
|
175
|
+
}
|
|
176
|
+
const model = config.model ?? createAnthropic()("claude-haiku-4-5-20251001");
|
|
177
|
+
const systemPrompt = config.systemPrompt ?? buildDefaultPrompt(config.projectContext);
|
|
178
|
+
let issueCreator;
|
|
179
|
+
if (config.github) {
|
|
180
|
+
const { token, repo, labels } = config.github;
|
|
181
|
+
issueCreator = async (params) => {
|
|
182
|
+
const mergedLabels = [
|
|
183
|
+
.../* @__PURE__ */ new Set([...labels ?? [], ...params.labels ?? []])
|
|
184
|
+
];
|
|
185
|
+
try {
|
|
186
|
+
const response = await fetch(
|
|
187
|
+
`https://api.github.com/repos/${repo}/issues`,
|
|
188
|
+
{
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: {
|
|
191
|
+
Authorization: `Bearer ${token}`,
|
|
192
|
+
Accept: "application/vnd.github+json",
|
|
193
|
+
"Content-Type": "application/json"
|
|
194
|
+
},
|
|
195
|
+
body: JSON.stringify({
|
|
196
|
+
title: params.title,
|
|
197
|
+
body: params.body,
|
|
198
|
+
labels: mergedLabels
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
);
|
|
202
|
+
if (!response.ok) return null;
|
|
203
|
+
const data = await response.json();
|
|
204
|
+
return data.html_url;
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
} else {
|
|
210
|
+
issueCreator = createGitHubIssue;
|
|
211
|
+
}
|
|
212
|
+
const tools = createTools(issueCreator);
|
|
213
|
+
const result = streamText({
|
|
214
|
+
model,
|
|
215
|
+
system: systemPrompt,
|
|
216
|
+
messages: await convertToModelMessages(messages),
|
|
217
|
+
stopWhen: stepCountIs(2),
|
|
218
|
+
tools
|
|
219
|
+
});
|
|
220
|
+
return result.toUIMessageStreamResponse();
|
|
221
|
+
};
|
|
222
|
+
return { POST };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/server/status-handler.ts
|
|
226
|
+
var GITHUB_API = "https://api.github.com/repos";
|
|
227
|
+
function issueEndpoint(config, issueNumber) {
|
|
228
|
+
return `${GITHUB_API}/${config.repo}/issues/${issueNumber}`;
|
|
229
|
+
}
|
|
230
|
+
function parseIssueNumber(param) {
|
|
231
|
+
if (!param) return null;
|
|
232
|
+
const n = parseInt(param, 10);
|
|
233
|
+
return isNaN(n) ? null : n;
|
|
234
|
+
}
|
|
235
|
+
function githubHeaders(token, withBody = false) {
|
|
236
|
+
const headers = {
|
|
237
|
+
Authorization: `Bearer ${token}`,
|
|
238
|
+
Accept: "application/vnd.github+json"
|
|
239
|
+
};
|
|
240
|
+
if (withBody) {
|
|
241
|
+
headers["Content-Type"] = "application/json";
|
|
242
|
+
}
|
|
243
|
+
return headers;
|
|
244
|
+
}
|
|
245
|
+
async function getIssue(config, issueNumber) {
|
|
246
|
+
const res = await fetch(issueEndpoint(config, issueNumber), {
|
|
247
|
+
headers: githubHeaders(config.token),
|
|
248
|
+
cache: "no-store"
|
|
249
|
+
});
|
|
250
|
+
if (!res.ok) return null;
|
|
251
|
+
return res.json();
|
|
252
|
+
}
|
|
253
|
+
async function findPR(config, issueNumber) {
|
|
254
|
+
const [owner] = config.repo.split("/");
|
|
255
|
+
const res = await fetch(
|
|
256
|
+
`${GITHUB_API}/${config.repo}/pulls?head=${owner}:feedback/issue-${issueNumber}&state=open`,
|
|
257
|
+
{ headers: githubHeaders(config.token), cache: "no-store" }
|
|
258
|
+
);
|
|
259
|
+
if (!res.ok) return null;
|
|
260
|
+
const pulls = await res.json();
|
|
261
|
+
if (!Array.isArray(pulls) || pulls.length === 0) return null;
|
|
262
|
+
const pr = pulls[0];
|
|
263
|
+
return { number: pr.number, html_url: pr.html_url, head_sha: pr.head?.sha };
|
|
264
|
+
}
|
|
265
|
+
async function getPreviewUrl(config, sha) {
|
|
266
|
+
const res = await fetch(
|
|
267
|
+
`${GITHUB_API}/${config.repo}/deployments?sha=${sha}&per_page=5`,
|
|
268
|
+
{ headers: githubHeaders(config.token), cache: "no-store" }
|
|
269
|
+
);
|
|
270
|
+
if (!res.ok) return null;
|
|
271
|
+
const deployments = await res.json();
|
|
272
|
+
if (!Array.isArray(deployments)) return null;
|
|
273
|
+
for (const deployment of deployments) {
|
|
274
|
+
const statusRes = await fetch(deployment.statuses_url, {
|
|
275
|
+
headers: githubHeaders(config.token),
|
|
276
|
+
cache: "no-store"
|
|
277
|
+
});
|
|
278
|
+
if (!statusRes.ok) continue;
|
|
279
|
+
const statuses = await statusRes.json();
|
|
280
|
+
const success = statuses.find(
|
|
281
|
+
(s) => s.state === "success" && s.environment_url
|
|
282
|
+
);
|
|
283
|
+
if (success) return success.environment_url;
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
async function getFailReason(config, issueNumber) {
|
|
288
|
+
const res = await fetch(
|
|
289
|
+
`${issueEndpoint(config, issueNumber)}/comments?per_page=5&direction=desc`,
|
|
290
|
+
{ headers: githubHeaders(config.token), cache: "no-store" }
|
|
291
|
+
);
|
|
292
|
+
if (!res.ok) return void 0;
|
|
293
|
+
const comments = await res.json();
|
|
294
|
+
const failComment = comments.find(
|
|
295
|
+
(c) => c.body?.startsWith("Agent failed:")
|
|
296
|
+
);
|
|
297
|
+
return failComment?.body?.replace("Agent failed:", "").trim();
|
|
298
|
+
}
|
|
299
|
+
async function isAgentRunning(agentUrl, issueNumber) {
|
|
300
|
+
if (!agentUrl) return false;
|
|
301
|
+
try {
|
|
302
|
+
const res = await fetch(`${agentUrl}/health`, { cache: "no-store" });
|
|
303
|
+
if (!res.ok) return false;
|
|
304
|
+
const data = await res.json();
|
|
305
|
+
return data.currentJob === issueNumber;
|
|
306
|
+
} catch {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async function deriveStage(config, issueNumber, agentUrl) {
|
|
311
|
+
const issue = await getIssue(config, issueNumber);
|
|
312
|
+
if (!issue) return null;
|
|
313
|
+
const labels = (issue.labels ?? []).map((l) => l.name);
|
|
314
|
+
const issueUrl = issue.html_url;
|
|
315
|
+
if (labels.includes("agent-failed")) {
|
|
316
|
+
const failReason = await getFailReason(config, issueNumber);
|
|
317
|
+
return { stage: "failed", issueUrl, failReason };
|
|
318
|
+
}
|
|
319
|
+
if (labels.includes("rejected")) {
|
|
320
|
+
return { stage: "rejected", issueUrl };
|
|
321
|
+
}
|
|
322
|
+
if (issue.state === "closed") {
|
|
323
|
+
return { stage: "deployed", issueUrl };
|
|
324
|
+
}
|
|
325
|
+
if (await isAgentRunning(agentUrl, issueNumber)) {
|
|
326
|
+
return { stage: "running", issueUrl };
|
|
327
|
+
}
|
|
328
|
+
if (labels.includes("preview-pending")) {
|
|
329
|
+
const pr = await findPR(config, issueNumber);
|
|
330
|
+
if (pr) {
|
|
331
|
+
const previewUrl = await getPreviewUrl(config, pr.head_sha);
|
|
332
|
+
if (previewUrl) {
|
|
333
|
+
return {
|
|
334
|
+
stage: "preview_ready",
|
|
335
|
+
issueUrl,
|
|
336
|
+
previewUrl,
|
|
337
|
+
prNumber: pr.number,
|
|
338
|
+
prUrl: pr.html_url
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return { stage: "validating", issueUrl };
|
|
343
|
+
}
|
|
344
|
+
if (labels.includes("in-progress")) {
|
|
345
|
+
return { stage: "validating", issueUrl };
|
|
346
|
+
}
|
|
347
|
+
if (labels.includes("feedback-bot")) {
|
|
348
|
+
return { stage: "queued", issueUrl };
|
|
349
|
+
}
|
|
350
|
+
return { stage: "created", issueUrl };
|
|
351
|
+
}
|
|
352
|
+
async function closeAndReopenIssue(url, headers) {
|
|
353
|
+
const closeRes = await fetch(url, {
|
|
354
|
+
method: "PATCH",
|
|
355
|
+
headers,
|
|
356
|
+
body: JSON.stringify({ state: "closed" })
|
|
357
|
+
});
|
|
358
|
+
if (!closeRes.ok) {
|
|
359
|
+
return Response.json({ error: "Failed to close issue" }, { status: 500 });
|
|
360
|
+
}
|
|
361
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
362
|
+
const reopenRes = await fetch(url, {
|
|
363
|
+
method: "PATCH",
|
|
364
|
+
headers,
|
|
365
|
+
body: JSON.stringify({ state: "open" })
|
|
366
|
+
});
|
|
367
|
+
if (!reopenRes.ok) {
|
|
368
|
+
return Response.json({ error: "Failed to reopen issue" }, { status: 500 });
|
|
369
|
+
}
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
function deleteFeedbackBranch(config, issueNumber, headers) {
|
|
373
|
+
return fetch(
|
|
374
|
+
`${GITHUB_API}/${config.repo}/git/refs/heads/feedback/issue-${issueNumber}`,
|
|
375
|
+
{ method: "DELETE", headers }
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
async function handleRetry(config, issueNumber) {
|
|
379
|
+
const headers = githubHeaders(config.token, true);
|
|
380
|
+
const url = issueEndpoint(config, issueNumber);
|
|
381
|
+
for (const label of ["agent-failed", "in-progress"]) {
|
|
382
|
+
const res = await fetch(`${url}/labels/${encodeURIComponent(label)}`, {
|
|
383
|
+
method: "DELETE",
|
|
384
|
+
headers
|
|
385
|
+
});
|
|
386
|
+
if (!res.ok && res.status !== 404) {
|
|
387
|
+
return Response.json({ error: "Failed to remove labels" }, { status: 500 });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const error = await closeAndReopenIssue(url, headers);
|
|
391
|
+
if (error) return error;
|
|
392
|
+
return Response.json({ retried: true });
|
|
393
|
+
}
|
|
394
|
+
async function handleApprove(config, issueNumber) {
|
|
395
|
+
const headers = githubHeaders(config.token, true);
|
|
396
|
+
const url = issueEndpoint(config, issueNumber);
|
|
397
|
+
const pr = await findPR(config, issueNumber);
|
|
398
|
+
if (!pr) {
|
|
399
|
+
return Response.json({ error: "Pull request not found" }, { status: 404 });
|
|
400
|
+
}
|
|
401
|
+
const mergeRes = await fetch(
|
|
402
|
+
`${GITHUB_API}/${config.repo}/pulls/${pr.number}/merge`,
|
|
403
|
+
{
|
|
404
|
+
method: "PUT",
|
|
405
|
+
headers,
|
|
406
|
+
body: JSON.stringify({ merge_method: "squash" })
|
|
407
|
+
}
|
|
408
|
+
);
|
|
409
|
+
if (!mergeRes.ok) {
|
|
410
|
+
if (mergeRes.status === 409) {
|
|
411
|
+
return Response.json(
|
|
412
|
+
{ error: "Merge conflict \u2014 contact administrator" },
|
|
413
|
+
{ status: 409 }
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
return Response.json({ error: "Merge failed" }, { status: 500 });
|
|
417
|
+
}
|
|
418
|
+
await fetch(url, {
|
|
419
|
+
method: "PATCH",
|
|
420
|
+
headers,
|
|
421
|
+
body: JSON.stringify({ state: "closed" })
|
|
422
|
+
});
|
|
423
|
+
await deleteFeedbackBranch(config, issueNumber, headers);
|
|
424
|
+
return Response.json({ approved: true });
|
|
425
|
+
}
|
|
426
|
+
async function handleReject(config, issueNumber) {
|
|
427
|
+
const headers = githubHeaders(config.token, true);
|
|
428
|
+
const url = issueEndpoint(config, issueNumber);
|
|
429
|
+
const pr = await findPR(config, issueNumber);
|
|
430
|
+
if (pr) {
|
|
431
|
+
await fetch(`${GITHUB_API}/${config.repo}/pulls/${pr.number}`, {
|
|
432
|
+
method: "PATCH",
|
|
433
|
+
headers,
|
|
434
|
+
body: JSON.stringify({ state: "closed" })
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
await fetch(`${url}/labels`, {
|
|
438
|
+
method: "POST",
|
|
439
|
+
headers,
|
|
440
|
+
body: JSON.stringify({ labels: ["rejected"] })
|
|
441
|
+
});
|
|
442
|
+
await fetch(url, {
|
|
443
|
+
method: "PATCH",
|
|
444
|
+
headers,
|
|
445
|
+
body: JSON.stringify({ state: "closed" })
|
|
446
|
+
});
|
|
447
|
+
await deleteFeedbackBranch(config, issueNumber, headers);
|
|
448
|
+
return Response.json({ rejected: true });
|
|
449
|
+
}
|
|
450
|
+
async function handleRequestChanges(config, issueNumber, comment) {
|
|
451
|
+
const headers = githubHeaders(config.token, true);
|
|
452
|
+
const url = issueEndpoint(config, issueNumber);
|
|
453
|
+
if (comment) {
|
|
454
|
+
await fetch(`${url}/comments`, {
|
|
455
|
+
method: "POST",
|
|
456
|
+
headers,
|
|
457
|
+
body: JSON.stringify({ body: `**Changes requested:**
|
|
458
|
+
|
|
459
|
+
${comment}` })
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
await fetch(`${url}/labels/${encodeURIComponent("preview-pending")}`, {
|
|
463
|
+
method: "DELETE",
|
|
464
|
+
headers
|
|
465
|
+
});
|
|
466
|
+
await fetch(`${url}/labels`, {
|
|
467
|
+
method: "POST",
|
|
468
|
+
headers,
|
|
469
|
+
body: JSON.stringify({ labels: ["auto-implement"] })
|
|
470
|
+
});
|
|
471
|
+
const error = await closeAndReopenIssue(url, headers);
|
|
472
|
+
if (error) return error;
|
|
473
|
+
return Response.json({ requested_changes: true });
|
|
474
|
+
}
|
|
475
|
+
var VALID_ACTIONS = ["retry", "approve", "reject", "request_changes"];
|
|
476
|
+
function resolveGitHubConfig(config) {
|
|
477
|
+
if (config.github) {
|
|
478
|
+
return config.github;
|
|
479
|
+
}
|
|
480
|
+
const repo = process.env.GITHUB_REPO;
|
|
481
|
+
const token = process.env.GITHUB_TOKEN;
|
|
482
|
+
if (!repo || !token) return null;
|
|
483
|
+
return { repo, token };
|
|
484
|
+
}
|
|
485
|
+
function resolveAgentUrl(config) {
|
|
486
|
+
return config.agentUrl ?? process.env.AGENT_URL;
|
|
487
|
+
}
|
|
488
|
+
function createStatusHandler(config) {
|
|
489
|
+
const GET = async (req) => {
|
|
490
|
+
const url = new URL(req.url);
|
|
491
|
+
const issueNumber = parseIssueNumber(url.searchParams.get("issue"));
|
|
492
|
+
if (!issueNumber) {
|
|
493
|
+
return Response.json({ error: "Missing or invalid issue parameter" }, { status: 400 });
|
|
494
|
+
}
|
|
495
|
+
const ghConfig = resolveGitHubConfig(config);
|
|
496
|
+
if (!ghConfig) {
|
|
497
|
+
return Response.json({ error: "GitHub not configured" }, { status: 500 });
|
|
498
|
+
}
|
|
499
|
+
const agentUrl = resolveAgentUrl(config);
|
|
500
|
+
const result = await deriveStage(ghConfig, issueNumber, agentUrl);
|
|
501
|
+
if (!result) {
|
|
502
|
+
return Response.json({ error: "Issue not found" }, { status: 404 });
|
|
503
|
+
}
|
|
504
|
+
const response = { issueNumber, ...result };
|
|
505
|
+
return Response.json(response);
|
|
506
|
+
};
|
|
507
|
+
const POST = async (req) => {
|
|
508
|
+
const url = new URL(req.url);
|
|
509
|
+
const action = url.searchParams.get("action");
|
|
510
|
+
if (!action || !VALID_ACTIONS.includes(action)) {
|
|
511
|
+
return Response.json({ error: "Invalid action" }, { status: 400 });
|
|
512
|
+
}
|
|
513
|
+
const issueNumber = parseIssueNumber(url.searchParams.get("issue"));
|
|
514
|
+
if (!issueNumber) {
|
|
515
|
+
return Response.json({ error: "Missing or invalid issue parameter" }, { status: 400 });
|
|
516
|
+
}
|
|
517
|
+
let body = {};
|
|
518
|
+
try {
|
|
519
|
+
body = await req.json();
|
|
520
|
+
} catch {
|
|
521
|
+
}
|
|
522
|
+
if (!config.password || body.password !== config.password) {
|
|
523
|
+
return Response.json({ error: "Invalid password" }, { status: 401 });
|
|
524
|
+
}
|
|
525
|
+
const ghConfig = resolveGitHubConfig(config);
|
|
526
|
+
if (!ghConfig) {
|
|
527
|
+
return Response.json({ error: "GitHub not configured" }, { status: 500 });
|
|
528
|
+
}
|
|
529
|
+
switch (action) {
|
|
530
|
+
case "retry":
|
|
531
|
+
return handleRetry(ghConfig, issueNumber);
|
|
532
|
+
case "approve":
|
|
533
|
+
return handleApprove(ghConfig, issueNumber);
|
|
534
|
+
case "reject":
|
|
535
|
+
return handleReject(ghConfig, issueNumber);
|
|
536
|
+
case "request_changes":
|
|
537
|
+
return handleRequestChanges(ghConfig, issueNumber, body.comment);
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
return { GET, POST };
|
|
541
|
+
}
|
|
542
|
+
export {
|
|
543
|
+
buildDefaultPrompt,
|
|
544
|
+
createFeedbackHandler,
|
|
545
|
+
createGitHubIssue,
|
|
546
|
+
createStatusHandler,
|
|
547
|
+
createTools
|
|
548
|
+
};
|