chorus-cli 0.4.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/index.js +1184 -0
- package/package.json +29 -0
- package/providers/azuredevops.js +202 -0
- package/providers/github.js +144 -0
- package/providers/index.js +51 -0
- package/scripts/postinstall.js +125 -0
- package/tools/coder.py +970 -0
- package/tools/mapper.py +465 -0
- package/tools/qa.py +528 -0
- package/tools/requirements.txt +3 -0
package/tools/qa.py
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
QA Chat — Multi-turn QA conversation tool powered by Claude + pluggable messengers.
|
|
4
|
+
|
|
5
|
+
Supports Teams (Playwright browser automation) and Slack (API-based).
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
echo '{"issue_number":4464,...}' | qa.py --messenger teams --auth teams-auth.json --qa "zuki dlomo"
|
|
9
|
+
echo '{"issue_number":4464,...}' | qa.py --messenger slack --qa "zuki dlomo"
|
|
10
|
+
|
|
11
|
+
Input (JSON on stdin):
|
|
12
|
+
issue_number, issue_title, issue_body, enriched_questions
|
|
13
|
+
|
|
14
|
+
Output (JSON on stdout):
|
|
15
|
+
completed, requirements, conversation_rounds, raw_responses
|
|
16
|
+
|
|
17
|
+
Progress is logged to stderr.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import anthropic
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
from abc import ABC, abstractmethod
|
|
27
|
+
|
|
28
|
+
# ── Config ──────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
MODEL = os.environ.get("QA_MODEL", "claude-sonnet-4-5-20250929")
|
|
31
|
+
MAX_ROUNDS = int(os.environ.get("QA_MAX_ROUNDS", "5"))
|
|
32
|
+
POLL_INTERVAL = int(os.environ.get("QA_POLL_INTERVAL", "60")) # seconds
|
|
33
|
+
POLL_TIMEOUT = int(os.environ.get("QA_POLL_TIMEOUT", "1800")) # 30 min
|
|
34
|
+
|
|
35
|
+
def is_token_limit_error(err):
|
|
36
|
+
msg = str(err)
|
|
37
|
+
return "token limit exceeded" in msg or "rate_limit_error" in msg
|
|
38
|
+
|
|
39
|
+
# ── Logging (stderr only) ──────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
def log(msg):
|
|
42
|
+
print(msg, file=sys.stderr, flush=True)
|
|
43
|
+
|
|
44
|
+
# ── Messenger Interface ────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
class Messenger(ABC):
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def connect(self):
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def navigate_to_chat(self, name: str):
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def send_message(self, text: str):
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def wait_for_response(self) -> str:
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
def close(self):
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
# ── Teams Messenger (Playwright) ──────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
class TeamsMessenger(Messenger):
|
|
70
|
+
def __init__(self, auth_path):
|
|
71
|
+
self.auth_path = auth_path
|
|
72
|
+
self.browser = None
|
|
73
|
+
self.page = None
|
|
74
|
+
|
|
75
|
+
def connect(self):
|
|
76
|
+
from playwright.sync_api import sync_playwright
|
|
77
|
+
self._pw = sync_playwright().start()
|
|
78
|
+
self.browser = self._pw.firefox.launch(headless=False)
|
|
79
|
+
context = self.browser.new_context(storage_state=self.auth_path)
|
|
80
|
+
self.page = context.new_page()
|
|
81
|
+
self.page.set_default_timeout(60000)
|
|
82
|
+
log(" Loading Teams...")
|
|
83
|
+
self.page.goto("https://teams.microsoft.com", wait_until="domcontentloaded", timeout=120000)
|
|
84
|
+
self.page.wait_for_timeout(60000)
|
|
85
|
+
log(" Teams loaded")
|
|
86
|
+
|
|
87
|
+
def navigate_to_chat(self, name):
|
|
88
|
+
page = self.page
|
|
89
|
+
log(f" Navigating to Chat section...")
|
|
90
|
+
chat_btn = page.locator('[data-tid="app-bar-Chat"]')
|
|
91
|
+
if chat_btn.count() == 0:
|
|
92
|
+
chat_btn = page.locator('button:has-text("Chat"), [aria-label="Chat"]').first
|
|
93
|
+
chat_btn.click()
|
|
94
|
+
page.wait_for_timeout(30000)
|
|
95
|
+
|
|
96
|
+
log(f" Searching for {name}...")
|
|
97
|
+
search = page.locator('#ms-searchux-input')
|
|
98
|
+
search.click()
|
|
99
|
+
page.wait_for_timeout(500)
|
|
100
|
+
search.fill(name)
|
|
101
|
+
page.wait_for_timeout(3000)
|
|
102
|
+
|
|
103
|
+
page.keyboard.press("ArrowDown")
|
|
104
|
+
page.wait_for_timeout(500)
|
|
105
|
+
page.keyboard.press("ArrowDown")
|
|
106
|
+
page.wait_for_timeout(500)
|
|
107
|
+
page.keyboard.press("Enter")
|
|
108
|
+
|
|
109
|
+
page.wait_for_timeout(5000)
|
|
110
|
+
log(f" Opened chat with {name}")
|
|
111
|
+
|
|
112
|
+
def send_message(self, text):
|
|
113
|
+
page = self.page
|
|
114
|
+
try:
|
|
115
|
+
compose = page.locator('[data-tid="ckeditor"]').first
|
|
116
|
+
compose.fill(text)
|
|
117
|
+
page.keyboard.press("Enter")
|
|
118
|
+
except Exception:
|
|
119
|
+
compose = page.locator('[role="textbox"]').first
|
|
120
|
+
compose.fill(text)
|
|
121
|
+
page.keyboard.press("Enter")
|
|
122
|
+
page.wait_for_timeout(1000)
|
|
123
|
+
|
|
124
|
+
def _snapshot(self):
|
|
125
|
+
page = self.page
|
|
126
|
+
msgs = page.locator('[data-tid="chat-pane-message"]')
|
|
127
|
+
count = msgs.count()
|
|
128
|
+
if count > 0:
|
|
129
|
+
last = msgs.last
|
|
130
|
+
mid = last.get_attribute("data-mid") or ""
|
|
131
|
+
content = last.locator("[data-message-content]")
|
|
132
|
+
text = (content.text_content() or "").strip() if content.count() > 0 else ""
|
|
133
|
+
return count, mid, text
|
|
134
|
+
return 0, "", ""
|
|
135
|
+
|
|
136
|
+
def wait_for_response(self):
|
|
137
|
+
before_count, before_mid, before_text = self._snapshot()
|
|
138
|
+
log(f" Tracking messages (count={before_count}, last_mid={before_mid})")
|
|
139
|
+
|
|
140
|
+
elapsed = 0
|
|
141
|
+
while elapsed < POLL_TIMEOUT:
|
|
142
|
+
self.page.wait_for_timeout(POLL_INTERVAL * 1000)
|
|
143
|
+
elapsed += POLL_INTERVAL
|
|
144
|
+
|
|
145
|
+
now_count, now_mid, now_text = self._snapshot()
|
|
146
|
+
|
|
147
|
+
if now_mid and now_mid != before_mid:
|
|
148
|
+
log(f" New message detected (mid={now_mid})")
|
|
149
|
+
return now_text
|
|
150
|
+
|
|
151
|
+
log(f" Waiting for QA response... ({elapsed}s, msgs={now_count})")
|
|
152
|
+
|
|
153
|
+
raise TimeoutError(f"No QA response after {POLL_TIMEOUT}s")
|
|
154
|
+
|
|
155
|
+
def close(self):
|
|
156
|
+
if self.browser:
|
|
157
|
+
self.browser.close()
|
|
158
|
+
if hasattr(self, '_pw'):
|
|
159
|
+
self._pw.stop()
|
|
160
|
+
|
|
161
|
+
# ── Slack Messenger (API) ─────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
class SlackMessenger(Messenger):
|
|
164
|
+
def __init__(self, bot_token):
|
|
165
|
+
self.bot_token = bot_token
|
|
166
|
+
self.client = None
|
|
167
|
+
self.channel_id = None
|
|
168
|
+
self.bot_user_id = None
|
|
169
|
+
self.last_ts = None
|
|
170
|
+
|
|
171
|
+
def connect(self):
|
|
172
|
+
from slack_sdk import WebClient
|
|
173
|
+
from slack_sdk.errors import SlackApiError
|
|
174
|
+
|
|
175
|
+
self.client = WebClient(token=self.bot_token)
|
|
176
|
+
auth = self.client.auth_test()
|
|
177
|
+
self.bot_user_id = auth["user_id"]
|
|
178
|
+
log(f" Slack connected as {auth['user']} (team: {auth['team']})")
|
|
179
|
+
|
|
180
|
+
def navigate_to_chat(self, name):
|
|
181
|
+
log(f" Finding Slack user '{name}'...")
|
|
182
|
+
cursor = None
|
|
183
|
+
target_user_id = None
|
|
184
|
+
|
|
185
|
+
while True:
|
|
186
|
+
kwargs = {"limit": 200}
|
|
187
|
+
if cursor:
|
|
188
|
+
kwargs["cursor"] = cursor
|
|
189
|
+
resp = self.client.users_list(**kwargs)
|
|
190
|
+
|
|
191
|
+
for member in resp["members"]:
|
|
192
|
+
if member.get("deleted") or member.get("is_bot"):
|
|
193
|
+
continue
|
|
194
|
+
profile = member.get("profile", {})
|
|
195
|
+
display = profile.get("display_name", "") or ""
|
|
196
|
+
real = profile.get("real_name", "") or ""
|
|
197
|
+
if name.lower() in display.lower() or name.lower() in real.lower():
|
|
198
|
+
target_user_id = member["id"]
|
|
199
|
+
log(f" Matched user: {real} ({display}) — {target_user_id}")
|
|
200
|
+
break
|
|
201
|
+
|
|
202
|
+
if target_user_id:
|
|
203
|
+
break
|
|
204
|
+
|
|
205
|
+
cursor = resp.get("response_metadata", {}).get("next_cursor")
|
|
206
|
+
if not cursor:
|
|
207
|
+
break
|
|
208
|
+
|
|
209
|
+
if not target_user_id:
|
|
210
|
+
raise RuntimeError(f"Could not find Slack user matching '{name}'")
|
|
211
|
+
|
|
212
|
+
dm = self.client.conversations_open(users=[target_user_id])
|
|
213
|
+
self.channel_id = dm["channel"]["id"]
|
|
214
|
+
log(f" Opened DM channel {self.channel_id}")
|
|
215
|
+
|
|
216
|
+
def send_message(self, text):
|
|
217
|
+
resp = self.client.chat_postMessage(channel=self.channel_id, text=text)
|
|
218
|
+
self.last_ts = resp["ts"]
|
|
219
|
+
log(f" Message sent (ts={self.last_ts})")
|
|
220
|
+
|
|
221
|
+
def wait_for_response(self):
|
|
222
|
+
log(f" Polling for Slack response (interval={POLL_INTERVAL}s, timeout={POLL_TIMEOUT}s)...")
|
|
223
|
+
elapsed = 0
|
|
224
|
+
while elapsed < POLL_TIMEOUT:
|
|
225
|
+
time.sleep(POLL_INTERVAL)
|
|
226
|
+
elapsed += POLL_INTERVAL
|
|
227
|
+
|
|
228
|
+
resp = self.client.conversations_history(
|
|
229
|
+
channel=self.channel_id,
|
|
230
|
+
oldest=self.last_ts,
|
|
231
|
+
limit=10,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
for msg in resp.get("messages", []):
|
|
235
|
+
# Skip our own bot messages
|
|
236
|
+
if msg.get("user") == self.bot_user_id:
|
|
237
|
+
continue
|
|
238
|
+
if msg.get("bot_id"):
|
|
239
|
+
continue
|
|
240
|
+
# Found a human reply
|
|
241
|
+
text = msg.get("text", "").strip()
|
|
242
|
+
if text:
|
|
243
|
+
self.last_ts = msg["ts"]
|
|
244
|
+
log(f" Slack response received ({len(text)} chars)")
|
|
245
|
+
return text
|
|
246
|
+
|
|
247
|
+
log(f" Waiting for QA response... ({elapsed}s)")
|
|
248
|
+
|
|
249
|
+
raise TimeoutError(f"No QA response after {POLL_TIMEOUT}s")
|
|
250
|
+
|
|
251
|
+
def close(self):
|
|
252
|
+
pass # No persistent connection to tear down
|
|
253
|
+
|
|
254
|
+
# ── Shared Helpers ─────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
def split_questions(text):
|
|
257
|
+
"""Split enriched questions into intro + individual numbered questions."""
|
|
258
|
+
import re
|
|
259
|
+
parts = re.split(r'\n(?=\d+\.\s)', text)
|
|
260
|
+
result = []
|
|
261
|
+
for part in parts:
|
|
262
|
+
stripped = part.strip()
|
|
263
|
+
if stripped:
|
|
264
|
+
result.append(stripped)
|
|
265
|
+
return result if len(result) > 1 else [text.strip()]
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
EVALUATE_TOOLS = [
|
|
269
|
+
{
|
|
270
|
+
"name": "evaluation",
|
|
271
|
+
"description": "Return your evaluation of the QA conversation.",
|
|
272
|
+
"input_schema": {
|
|
273
|
+
"type": "object",
|
|
274
|
+
"properties": {
|
|
275
|
+
"sufficient": {
|
|
276
|
+
"type": "boolean",
|
|
277
|
+
"description": "True if there is enough info to write exact developer requirements.",
|
|
278
|
+
},
|
|
279
|
+
"reasoning": {
|
|
280
|
+
"type": "string",
|
|
281
|
+
"description": "Brief explanation of why info is or isn't sufficient.",
|
|
282
|
+
},
|
|
283
|
+
"follow_up": {
|
|
284
|
+
"type": "string",
|
|
285
|
+
"description": "Follow-up message to send to QA. Required if sufficient is false. MUST be plain text — no markdown, no **bold**, no bullets. Use numbered lines (1. 2. 3.) for multiple questions.",
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
"required": ["sufficient", "reasoning"],
|
|
289
|
+
},
|
|
290
|
+
}
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def evaluate_response(client, conversation, issue_context):
|
|
295
|
+
convo_text = "\n\n".join(
|
|
296
|
+
f"{'QA' if m['from'] == 'qa' else 'Bot'}: {m['text']}" for m in conversation
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
messages = [
|
|
300
|
+
{
|
|
301
|
+
"role": "user",
|
|
302
|
+
"content": f"""Evaluate this QA conversation about a GitHub issue.
|
|
303
|
+
|
|
304
|
+
ISSUE:
|
|
305
|
+
Number: {issue_context['issue_number']}
|
|
306
|
+
Title: {issue_context['issue_title']}
|
|
307
|
+
Body: {issue_context.get('issue_body', 'N/A')}
|
|
308
|
+
|
|
309
|
+
CONVERSATION SO FAR:
|
|
310
|
+
{convo_text}
|
|
311
|
+
|
|
312
|
+
Decide: do you have enough specific information to write exact, actionable developer requirements?
|
|
313
|
+
|
|
314
|
+
If YES: set sufficient=true and explain why.
|
|
315
|
+
If NO: set sufficient=false and write a short, friendly follow-up message asking QA for the missing details. Be specific about what you still need — don't repeat questions already answered.""",
|
|
316
|
+
}
|
|
317
|
+
]
|
|
318
|
+
|
|
319
|
+
response = client.messages.create(
|
|
320
|
+
model=MODEL,
|
|
321
|
+
max_tokens=1024,
|
|
322
|
+
system=(
|
|
323
|
+
"You are evaluating a QA conversation about a software bug/feature. "
|
|
324
|
+
"Your job is to decide if there is enough concrete information to write "
|
|
325
|
+
"exact developer requirements. Vague answers like 'it should work properly' "
|
|
326
|
+
"are NOT sufficient — you need specifics: exact behavior, exact UI elements, "
|
|
327
|
+
"exact data flows, exact error messages, etc. "
|
|
328
|
+
"Use the evaluation tool to return your assessment. "
|
|
329
|
+
"IMPORTANT: follow_up messages are sent via chat. Use plain text only — "
|
|
330
|
+
"no markdown, no **bold**, no *italic*, no bullet points. "
|
|
331
|
+
"Use numbered lines (1. 2. 3.) for multiple questions. Keep it conversational."
|
|
332
|
+
),
|
|
333
|
+
tools=EVALUATE_TOOLS,
|
|
334
|
+
tool_choice={"type": "tool", "name": "evaluation"},
|
|
335
|
+
messages=messages,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
if hasattr(response, "usage") and response.usage:
|
|
339
|
+
log(f" Evaluate tokens: {response.usage.input_tokens} in / {response.usage.output_tokens} out")
|
|
340
|
+
|
|
341
|
+
for block in response.content:
|
|
342
|
+
if block.type == "tool_use" and block.name == "evaluation":
|
|
343
|
+
return block.input
|
|
344
|
+
|
|
345
|
+
raise RuntimeError("Claude did not return evaluation tool call")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def synthesize(client, conversation, issue_context):
|
|
349
|
+
convo_text = "\n\n".join(
|
|
350
|
+
f"{'QA' if m['from'] == 'qa' else 'Bot'}: {m['text']}" for m in conversation
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
messages = [
|
|
354
|
+
{
|
|
355
|
+
"role": "user",
|
|
356
|
+
"content": f"""Synthesize this QA conversation into exact, actionable developer requirements.
|
|
357
|
+
|
|
358
|
+
ISSUE:
|
|
359
|
+
Number: {issue_context['issue_number']}
|
|
360
|
+
Title: {issue_context['issue_title']}
|
|
361
|
+
Body: {issue_context.get('issue_body', 'N/A')}
|
|
362
|
+
|
|
363
|
+
FULL QA CONVERSATION:
|
|
364
|
+
{convo_text}
|
|
365
|
+
|
|
366
|
+
Write a clear numbered list of requirements. Each requirement should be specific enough that a developer can implement it without asking further questions. Include exact UI elements, exact behavior, exact data flows, edge cases, and acceptance criteria where discussed.""",
|
|
367
|
+
}
|
|
368
|
+
]
|
|
369
|
+
|
|
370
|
+
response = client.messages.create(
|
|
371
|
+
model=MODEL,
|
|
372
|
+
max_tokens=2048,
|
|
373
|
+
system=(
|
|
374
|
+
"You synthesize QA conversations into exact, actionable developer requirements. "
|
|
375
|
+
"Be specific and concrete. No vague language. Every requirement should be testable."
|
|
376
|
+
),
|
|
377
|
+
messages=messages,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
if hasattr(response, "usage") and response.usage:
|
|
381
|
+
log(f" Synthesize tokens: {response.usage.input_tokens} in / {response.usage.output_tokens} out")
|
|
382
|
+
|
|
383
|
+
return "".join(block.text for block in response.content if block.type == "text").strip()
|
|
384
|
+
|
|
385
|
+
# ── Main Loop ───────────────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
def run_qa_chat(issue_context, messenger, qa_name):
|
|
388
|
+
proxy_url = os.environ.get("CODER_PROXY_URL")
|
|
389
|
+
if proxy_url:
|
|
390
|
+
client = anthropic.Anthropic(base_url=proxy_url.rstrip('/'))
|
|
391
|
+
else:
|
|
392
|
+
client = anthropic.Anthropic()
|
|
393
|
+
conversation = []
|
|
394
|
+
raw_responses = []
|
|
395
|
+
|
|
396
|
+
log(f"Opening chat with {qa_name}...")
|
|
397
|
+
|
|
398
|
+
messenger.connect()
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
messenger.navigate_to_chat(qa_name)
|
|
402
|
+
|
|
403
|
+
# Split enriched questions into separate messages
|
|
404
|
+
initial_msg = issue_context["enriched_questions"]
|
|
405
|
+
parts = split_questions(initial_msg)
|
|
406
|
+
for i, part in enumerate(parts):
|
|
407
|
+
messenger.send_message(part)
|
|
408
|
+
log(f" Sent message {i + 1}/{len(parts)}")
|
|
409
|
+
time.sleep(1)
|
|
410
|
+
conversation.append({"from": "bot", "text": initial_msg})
|
|
411
|
+
log("Sent initial questions to QA")
|
|
412
|
+
|
|
413
|
+
rounds = 0
|
|
414
|
+
for round_num in range(1, MAX_ROUNDS + 1):
|
|
415
|
+
log(f"Round {round_num}/{MAX_ROUNDS}: waiting for QA...")
|
|
416
|
+
response_text = messenger.wait_for_response()
|
|
417
|
+
conversation.append({"from": "qa", "text": response_text})
|
|
418
|
+
raw_responses.append(response_text)
|
|
419
|
+
rounds = round_num
|
|
420
|
+
log(f"Round {round_num}: QA responded ({len(response_text)} chars)")
|
|
421
|
+
|
|
422
|
+
evaluation = evaluate_response(client, conversation, issue_context)
|
|
423
|
+
log(f"Round {round_num}: sufficient={evaluation['sufficient']} — {evaluation.get('reasoning', '')[:100]}")
|
|
424
|
+
|
|
425
|
+
if evaluation["sufficient"]:
|
|
426
|
+
break
|
|
427
|
+
|
|
428
|
+
if round_num < MAX_ROUNDS:
|
|
429
|
+
follow_up = evaluation.get("follow_up", "")
|
|
430
|
+
if follow_up:
|
|
431
|
+
parts = split_questions(follow_up)
|
|
432
|
+
for part in parts:
|
|
433
|
+
messenger.send_message(part)
|
|
434
|
+
time.sleep(1)
|
|
435
|
+
conversation.append({"from": "bot", "text": follow_up})
|
|
436
|
+
log(f"Round {round_num}: sent follow-up ({len(parts)} messages)")
|
|
437
|
+
else:
|
|
438
|
+
log(f"Round {round_num}: no follow-up generated, stopping")
|
|
439
|
+
break
|
|
440
|
+
|
|
441
|
+
finally:
|
|
442
|
+
messenger.close()
|
|
443
|
+
|
|
444
|
+
# Synthesize requirements from full conversation
|
|
445
|
+
log("Synthesizing requirements...")
|
|
446
|
+
requirements = synthesize(client, conversation, issue_context)
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
"completed": True,
|
|
450
|
+
"requirements": requirements,
|
|
451
|
+
"conversation_rounds": rounds,
|
|
452
|
+
"raw_responses": raw_responses,
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
# ── CLI ─────────────────────────────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
def main():
|
|
458
|
+
parser = argparse.ArgumentParser(description="QA Chat — multi-turn QA conversation tool")
|
|
459
|
+
parser.add_argument("--messenger", choices=["teams", "slack"], default="teams",
|
|
460
|
+
help="Messenger to use for QA chat (default: teams)")
|
|
461
|
+
parser.add_argument("--auth", help="Path to Teams auth state JSON (required for --messenger teams)")
|
|
462
|
+
parser.add_argument("--qa", required=True, help="QA person's name")
|
|
463
|
+
parser.add_argument("--super", action="store_true", help="Use Opus 4.6 instead of Sonnet")
|
|
464
|
+
args = parser.parse_args()
|
|
465
|
+
|
|
466
|
+
if args.super:
|
|
467
|
+
global MODEL
|
|
468
|
+
MODEL = "claude-opus-4-6"
|
|
469
|
+
log(f"Super mode: using {MODEL}")
|
|
470
|
+
|
|
471
|
+
if not os.environ.get("ANTHROPIC_API_KEY"):
|
|
472
|
+
log("Error: ANTHROPIC_API_KEY not set")
|
|
473
|
+
sys.exit(1)
|
|
474
|
+
|
|
475
|
+
# Build the appropriate messenger
|
|
476
|
+
if args.messenger == "teams":
|
|
477
|
+
if not args.auth:
|
|
478
|
+
log("Error: --auth is required when using --messenger teams")
|
|
479
|
+
sys.exit(1)
|
|
480
|
+
messenger = TeamsMessenger(args.auth)
|
|
481
|
+
elif args.messenger == "slack":
|
|
482
|
+
slack_token = os.environ.get("SLACK_BOT_TOKEN", "")
|
|
483
|
+
if not slack_token:
|
|
484
|
+
log("Error: SLACK_BOT_TOKEN environment variable is required for --messenger slack")
|
|
485
|
+
sys.exit(1)
|
|
486
|
+
messenger = SlackMessenger(slack_token)
|
|
487
|
+
|
|
488
|
+
# Read issue context from stdin
|
|
489
|
+
try:
|
|
490
|
+
issue_context = json.load(sys.stdin)
|
|
491
|
+
except json.JSONDecodeError as e:
|
|
492
|
+
log(f"Error: invalid JSON on stdin: {e}")
|
|
493
|
+
sys.exit(1)
|
|
494
|
+
|
|
495
|
+
for key in ("issue_number", "issue_title", "enriched_questions"):
|
|
496
|
+
if key not in issue_context:
|
|
497
|
+
log(f"Error: missing required field '{key}' in input JSON")
|
|
498
|
+
sys.exit(1)
|
|
499
|
+
|
|
500
|
+
try:
|
|
501
|
+
result = run_qa_chat(issue_context, messenger, args.qa)
|
|
502
|
+
print(json.dumps(result, indent=2))
|
|
503
|
+
except KeyboardInterrupt:
|
|
504
|
+
print(json.dumps({
|
|
505
|
+
"completed": False,
|
|
506
|
+
"requirements": "",
|
|
507
|
+
"conversation_rounds": 0,
|
|
508
|
+
"raw_responses": [],
|
|
509
|
+
"error": "Interrupted by user",
|
|
510
|
+
}, indent=2))
|
|
511
|
+
sys.exit(130)
|
|
512
|
+
except Exception as e:
|
|
513
|
+
if is_token_limit_error(e):
|
|
514
|
+
log("Token limit reached — stopping.")
|
|
515
|
+
else:
|
|
516
|
+
log(f"Fatal error: {e}")
|
|
517
|
+
print(json.dumps({
|
|
518
|
+
"completed": False,
|
|
519
|
+
"requirements": "",
|
|
520
|
+
"conversation_rounds": 0,
|
|
521
|
+
"raw_responses": [],
|
|
522
|
+
"error": str(e),
|
|
523
|
+
}, indent=2))
|
|
524
|
+
sys.exit(1)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
if __name__ == "__main__":
|
|
528
|
+
main()
|