typeclaw 0.28.1 → 0.29.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/package.json +1 -1
- package/src/agent/index.ts +37 -5
- package/src/agent/loop-guard.ts +112 -26
- package/src/agent/plugin-tools.ts +102 -41
- package/src/agent/session-origin.ts +3 -3
- package/src/agent/subagents.ts +7 -0
- package/src/agent/system-prompt.ts +29 -4
- package/src/agent/tools/channel-reply.ts +1 -0
- package/src/agent/tools/channel-send.ts +2 -1
- package/src/agent/tools/spawn-subagent.ts +21 -0
- package/src/agent/tools/subagent-output.ts +7 -3
- package/src/agent/tools/wikipedia.ts +1 -1
- package/src/bundled-plugins/explorer/explorer.ts +2 -0
- package/src/bundled-plugins/github-cli-auth/approve-idempotency.ts +74 -0
- package/src/bundled-plugins/github-cli-auth/effective-approval.ts +98 -0
- package/src/bundled-plugins/github-cli-auth/gh-review-inline-detect.ts +130 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +27 -2
- package/src/bundled-plugins/github-cli-auth/review-recorder.ts +12 -4
- package/src/bundled-plugins/memory/memory-logger.ts +3 -3
- package/src/bundled-plugins/operator/operator.ts +2 -0
- package/src/bundled-plugins/planner/index.ts +11 -0
- package/src/bundled-plugins/planner/planner.ts +282 -0
- package/src/bundled-plugins/planner/skills/general.ts +65 -0
- package/src/bundled-plugins/planner/skills/project.ts +69 -0
- package/src/bundled-plugins/researcher/index.ts +11 -0
- package/src/bundled-plugins/researcher/researcher.ts +226 -0
- package/src/bundled-plugins/researcher/skills/general.ts +105 -0
- package/src/bundled-plugins/researcher/write-report.ts +107 -0
- package/src/bundled-plugins/reviewer/reviewer.ts +29 -11
- package/src/bundled-plugins/reviewer/skills/data-review.ts +77 -0
- package/src/bundled-plugins/reviewer/skills/doc-review.ts +79 -0
- package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
- package/src/bundled-plugins/reviewer/skills/plan-review.ts +64 -0
- package/src/bundled-plugins/reviewer/skills/security-audit.ts +70 -0
- package/src/bundled-plugins/reviewer/skills/writing-review.ts +63 -0
- package/src/bundled-plugins/scout/scout.ts +2 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +8 -4
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +3 -2
- package/src/channels/adapters/discord-bot.ts +38 -11
- package/src/channels/adapters/github/inbound.ts +74 -9
- package/src/channels/adapters/github/index.ts +36 -11
- package/src/channels/adapters/github/reconcile-open-prs.ts +306 -0
- package/src/channels/adapters/github/review-state.ts +71 -2
- package/src/channels/adapters/kakaotalk-classify.ts +2 -2
- package/src/channels/adapters/kakaotalk.ts +2 -2
- package/src/channels/adapters/slack-bot-classify.ts +1 -1
- package/src/channels/adapters/slack-bot.ts +3 -0
- package/src/channels/adapters/telegram-bot.ts +3 -0
- package/src/channels/engagement.ts +12 -7
- package/src/channels/github-rereview-guard.ts +32 -8
- package/src/channels/github-review-claim.ts +53 -6
- package/src/channels/router.ts +44 -9
- package/src/channels/schema.ts +4 -3
- package/src/channels/types.ts +17 -6
- package/src/cli/init.ts +13 -2
- package/src/cli/ui.ts +64 -0
- package/src/config/config.ts +21 -15
- package/src/container/start.ts +5 -1
- package/src/init/dockerfile.ts +19 -56
- package/src/init/hatching.ts +1 -1
- package/src/init/index.ts +5 -1
- package/src/run/bundled-plugins.ts +4 -0
- package/src/server/index.ts +24 -5
- package/src/shared/host-locale.ts +27 -0
- package/src/shared/protocol.ts +1 -1
- package/src/shared/wordmark.ts +19 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +1 -1
- package/src/skills/typeclaw-config/SKILL.md +32 -32
- package/src/skills/typeclaw-kaomoji/SKILL.md +3 -3
- package/src/skills/typeclaw-tunnels/SKILL.md +3 -1
- package/src/tui/banner.ts +19 -0
- package/src/tui/format.ts +34 -0
- package/src/tui/index.ts +121 -22
- package/src/tui/theme.ts +26 -1
- package/src/tunnels/providers/cloudflare-named.ts +15 -4
- package/src/tunnels/providers/cloudflare-quick.ts +15 -4
- package/src/tunnels/providers/cloudflared-binary.ts +11 -0
- package/typeclaw.schema.json +15 -7
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { GithubTokenBridge } from '@/channels/github-token-bridge'
|
|
2
2
|
import type { ChannelRouter } from '@/channels/router'
|
|
3
3
|
import type { ChannelAdapterConfig, GithubAdapterConfig } from '@/channels/schema'
|
|
4
|
-
import type { ChannelSelfIdentityResolver } from '@/channels/types'
|
|
4
|
+
import type { ChannelSelfIdentityResolver, InboundMessage } from '@/channels/types'
|
|
5
5
|
import { resolveSecret } from '@/secrets/resolve'
|
|
6
6
|
import type { GithubSecretsBlock } from '@/secrets/schema'
|
|
7
7
|
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
parseListHooksPermissionStatus,
|
|
22
22
|
} from './permission-guidance'
|
|
23
23
|
import { createGithubReactionCallback, createGithubRemoveReactionCallback } from './reactions'
|
|
24
|
+
import { reconcileOpenPrs } from './reconcile-open-prs'
|
|
24
25
|
import { createGithubReviewStateResolver } from './review-state'
|
|
25
26
|
import { createGithubReviewThreadResolver } from './review-thread-resolver'
|
|
26
27
|
import { createTeamMembershipChecker } from './team-membership'
|
|
@@ -160,6 +161,19 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
160
161
|
const typing = async (): Promise<void> => {}
|
|
161
162
|
const dedup = createDeliveryDedup()
|
|
162
163
|
const isBotInTeam = createTeamMembershipChecker({ token: authToken, fetchImpl })
|
|
164
|
+
// Shared inbound entry. Both the live webhook handler and the startup
|
|
165
|
+
// reconciliation pass route through this so a replayed PR takes the exact
|
|
166
|
+
// same path a real delivery would.
|
|
167
|
+
const routeInbound = (message: InboundMessage): void => {
|
|
168
|
+
rememberWorkspace(message.workspace, message.chat)
|
|
169
|
+
// Ack-first: wrap in Promise.resolve so a synchronous throw inside
|
|
170
|
+
// router.route() cannot prevent the 200 response from being returned.
|
|
171
|
+
void Promise.resolve()
|
|
172
|
+
.then(() => options.router.route(message))
|
|
173
|
+
.catch((err: unknown) => {
|
|
174
|
+
logger.error(`[github] route failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
175
|
+
})
|
|
176
|
+
}
|
|
163
177
|
const handler = createGithubWebhookHandler({
|
|
164
178
|
webhookSecret,
|
|
165
179
|
dedup,
|
|
@@ -173,16 +187,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
173
187
|
authToken,
|
|
174
188
|
fetchImpl,
|
|
175
189
|
logger,
|
|
176
|
-
route:
|
|
177
|
-
rememberWorkspace(message.workspace, message.chat)
|
|
178
|
-
// Ack-first: wrap in Promise.resolve so a synchronous throw inside
|
|
179
|
-
// router.route() cannot prevent the 200 response from being returned.
|
|
180
|
-
void Promise.resolve()
|
|
181
|
-
.then(() => options.router.route(message))
|
|
182
|
-
.catch((err: unknown) => {
|
|
183
|
-
logger.error(`[github] route failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
184
|
-
})
|
|
185
|
-
},
|
|
190
|
+
route: routeInbound,
|
|
186
191
|
})
|
|
187
192
|
|
|
188
193
|
return {
|
|
@@ -330,6 +335,26 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
330
335
|
)
|
|
331
336
|
logRegistrationOutcome(logger, registration, options.secrets.auth.type)
|
|
332
337
|
}
|
|
338
|
+
// Catch up on PRs whose opened/ready_for_review/review_requested delivery
|
|
339
|
+
// was missed (tunnel-URL churn, dropped delivery, downtime). Best-effort
|
|
340
|
+
// and last so a failure here never blocks the adapter from coming up; the
|
|
341
|
+
// helper swallows per-repo errors internally. Runs on every start(), so a
|
|
342
|
+
// tunnel-driven restart re-checks too. `off` short-circuits inside.
|
|
343
|
+
if (repos.length > 0) {
|
|
344
|
+
await reconcileOpenPrs({
|
|
345
|
+
repos,
|
|
346
|
+
reviewOn: cfg.review.on,
|
|
347
|
+
selfLogin,
|
|
348
|
+
authType: options.secrets.auth.type,
|
|
349
|
+
token: authToken,
|
|
350
|
+
route: routeInbound,
|
|
351
|
+
logger,
|
|
352
|
+
isBotInTeam,
|
|
353
|
+
fetchImpl,
|
|
354
|
+
}).catch((err: unknown) => {
|
|
355
|
+
logger.warn(`[github] reconcile pass failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
356
|
+
})
|
|
357
|
+
}
|
|
333
358
|
},
|
|
334
359
|
async stop(): Promise<void> {
|
|
335
360
|
if (!started) return
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import type { GithubReviewOn } from '@/channels/schema'
|
|
2
|
+
import type { InboundMessage } from '@/channels/types'
|
|
3
|
+
|
|
4
|
+
import type { GithubAuthContext } from './auth'
|
|
5
|
+
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
6
|
+
import type { TeamMembershipChecker } from './team-membership'
|
|
7
|
+
|
|
8
|
+
// Catches up on review work that a live webhook delivery missed. The github
|
|
9
|
+
// adapter only acts on deliveries it receives, so a `pull_request.opened` /
|
|
10
|
+
// `ready_for_review` dropped while the tunnel URL was churning (cloudflare-quick
|
|
11
|
+
// mints a fresh host every restart) leaves an open PR permanently un-reviewed —
|
|
12
|
+
// nothing wakes the bot for it again. This pass runs on every adapter start()
|
|
13
|
+
// (cold boot AND tunnel-driven restart) and replays the PRs that still need a
|
|
14
|
+
// review as synthetic inbounds through the same router path a real webhook uses.
|
|
15
|
+
//
|
|
16
|
+
// It is intentionally NOT a substitute for webhooks: it is a floor, not the
|
|
17
|
+
// primary path. Drift between a missed delivery and the next start() is the
|
|
18
|
+
// reconciliation window; webhooks remain the low-latency path when delivery
|
|
19
|
+
// works.
|
|
20
|
+
|
|
21
|
+
export type ReconcileOpenPrsOptions = {
|
|
22
|
+
repos: readonly string[]
|
|
23
|
+
reviewOn: GithubReviewOn
|
|
24
|
+
selfLogin: string | null
|
|
25
|
+
authType: 'pat' | 'app'
|
|
26
|
+
token: (context?: GithubAuthContext) => Promise<string>
|
|
27
|
+
route: (message: InboundMessage) => void
|
|
28
|
+
logger: { info: (m: string) => void; warn: (m: string) => void }
|
|
29
|
+
// Resolves whether the bot is a member of a requested team, gating
|
|
30
|
+
// team-requested reviews under review.on 'review_requested' (mirrors the
|
|
31
|
+
// live webhook path's isMyTeam check in classifyReviewRequest). Omitted in
|
|
32
|
+
// tests that don't exercise team requests; treated as "not a member".
|
|
33
|
+
isBotInTeam?: TeamMembershipChecker
|
|
34
|
+
fetchImpl?: typeof fetch
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type ReconcileOutcome = { repo: string; scanned: number; replayed: number } | { repo: string; error: string }
|
|
38
|
+
|
|
39
|
+
const BOT_LOGIN_SUFFIX = '[bot]'
|
|
40
|
+
|
|
41
|
+
export async function reconcileOpenPrs(options: ReconcileOpenPrsOptions): Promise<ReconcileOutcome[]> {
|
|
42
|
+
// `off` disables code review entirely, so there is nothing to catch up on.
|
|
43
|
+
if (options.reviewOn === 'off') return []
|
|
44
|
+
if (options.selfLogin === null) return []
|
|
45
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
46
|
+
const selfLogin = options.selfLogin
|
|
47
|
+
const decoyLogin = resolveDecoyLogin(selfLogin, options.authType)
|
|
48
|
+
|
|
49
|
+
const outcomes: ReconcileOutcome[] = []
|
|
50
|
+
for (const repo of new Set(options.repos)) {
|
|
51
|
+
const target = parseRepo(repo)
|
|
52
|
+
if (target === null) {
|
|
53
|
+
outcomes.push({ repo, error: 'malformed repo slug' })
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const outcome = await reconcileRepo(target, options, selfLogin, decoyLogin, fetchImpl)
|
|
58
|
+
outcomes.push(outcome)
|
|
59
|
+
if (outcome.replayed > 0) {
|
|
60
|
+
options.logger.info(`[github] reconcile ${repo}: replayed ${outcome.replayed}/${outcome.scanned} open PR(s)`)
|
|
61
|
+
}
|
|
62
|
+
} catch (err) {
|
|
63
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
64
|
+
options.logger.warn(`[github] reconcile ${repo} failed: ${message}`)
|
|
65
|
+
outcomes.push({ repo, error: message })
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return outcomes
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function reconcileRepo(
|
|
72
|
+
target: RepoTarget,
|
|
73
|
+
options: ReconcileOpenPrsOptions,
|
|
74
|
+
selfLogin: string,
|
|
75
|
+
decoyLogin: string | null,
|
|
76
|
+
fetchImpl: typeof fetch,
|
|
77
|
+
): Promise<{ repo: string; scanned: number; replayed: number }> {
|
|
78
|
+
const repo = `${target.owner}/${target.repo}`
|
|
79
|
+
const token = await options.token({ repoSlug: repo })
|
|
80
|
+
const prs = await fetchOpenPrs(fetchImpl, token, target)
|
|
81
|
+
|
|
82
|
+
let replayed = 0
|
|
83
|
+
for (const pr of prs) {
|
|
84
|
+
const needs = await prNeedsReview({ pr, options, target, token, selfLogin, decoyLogin, fetchImpl })
|
|
85
|
+
if (!needs) continue
|
|
86
|
+
options.route(buildSyntheticInbound(pr, target))
|
|
87
|
+
replayed += 1
|
|
88
|
+
}
|
|
89
|
+
return { repo, scanned: prs.length, replayed }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function prNeedsReview(input: {
|
|
93
|
+
pr: OpenPr
|
|
94
|
+
options: ReconcileOpenPrsOptions
|
|
95
|
+
target: RepoTarget
|
|
96
|
+
token: string
|
|
97
|
+
selfLogin: string
|
|
98
|
+
decoyLogin: string | null
|
|
99
|
+
fetchImpl: typeof fetch
|
|
100
|
+
}): Promise<boolean> {
|
|
101
|
+
const { pr, options, target, token, selfLogin, decoyLogin, fetchImpl } = input
|
|
102
|
+
if (isSelfAuthored(pr, selfLogin, decoyLogin)) return false
|
|
103
|
+
|
|
104
|
+
if (options.reviewOn === 'review_requested') {
|
|
105
|
+
// Only the explicit request wakes a review under this mode. A draft can
|
|
106
|
+
// still carry a request, so draft state is irrelevant here. Mirrors the
|
|
107
|
+
// live path's `isMeAsUser || isMyTeam`: a direct user request, OR a team
|
|
108
|
+
// request the bot is a member of.
|
|
109
|
+
if (isUserReviewRequestedFromSelf(pr, selfLogin, decoyLogin)) return true
|
|
110
|
+
return await isTeamReviewRequestedFromSelf(pr, target, selfLogin, options.isBotInTeam)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// reviewOn === 'opened': a non-draft PR the bot has not yet reviewed. Draft
|
|
114
|
+
// PRs wait for the ready_for_review trigger, matching the live-webhook path.
|
|
115
|
+
if (pr.draft) return false
|
|
116
|
+
return !(await botAlreadyReviewed(fetchImpl, token, target, pr.number, selfLogin))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function isTeamReviewRequestedFromSelf(
|
|
120
|
+
pr: OpenPr,
|
|
121
|
+
target: RepoTarget,
|
|
122
|
+
selfLogin: string,
|
|
123
|
+
isBotInTeam: TeamMembershipChecker | undefined,
|
|
124
|
+
): Promise<boolean> {
|
|
125
|
+
if (isBotInTeam === undefined || pr.requestedTeamSlugs.length === 0) return false
|
|
126
|
+
for (const slug of pr.requestedTeamSlugs) {
|
|
127
|
+
if (await isBotInTeam({ org: target.owner, slug, login: selfLogin })) return true
|
|
128
|
+
}
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildSyntheticInbound(pr: OpenPr, target: RepoTarget): InboundMessage {
|
|
133
|
+
const branchSegment = pr.headRef !== null && pr.baseRef !== null ? ` Branch: ${pr.headRef} → ${pr.baseRef}.` : ''
|
|
134
|
+
const text =
|
|
135
|
+
`@${pr.authorLogin} opened PR #${pr.number}: "${pr.title}".${branchSegment}` +
|
|
136
|
+
' Please review the changes line-by-line and post your feedback.'
|
|
137
|
+
// Distinct from the live `pr-<id>-opened-<updatedAt>` id so a replay never
|
|
138
|
+
// collides with a real opened delivery, while repeated reconciles for the
|
|
139
|
+
// same unchanged PR dedupe against each other (same updatedAt).
|
|
140
|
+
const externalMessageId = `pr-${pr.id}-reconcile-${pr.updatedAt}`
|
|
141
|
+
return {
|
|
142
|
+
adapter: 'github',
|
|
143
|
+
workspace: `${target.owner}/${target.repo}`,
|
|
144
|
+
chat: `pr:${pr.number}`,
|
|
145
|
+
thread: null,
|
|
146
|
+
text,
|
|
147
|
+
externalMessageId,
|
|
148
|
+
authorId: String(pr.authorId),
|
|
149
|
+
authorName: pr.authorLogin,
|
|
150
|
+
authorIsBot: pr.authorIsBot,
|
|
151
|
+
isBotMention: true,
|
|
152
|
+
replyToBotMessageId: null,
|
|
153
|
+
mentionsOthers: false,
|
|
154
|
+
replyToOtherMessageId: null,
|
|
155
|
+
isDm: false,
|
|
156
|
+
ts: pr.updatedAt !== '' ? Date.parse(pr.updatedAt) || 0 : 0,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
type RepoTarget = { owner: string; repo: string }
|
|
161
|
+
|
|
162
|
+
type OpenPr = {
|
|
163
|
+
number: number
|
|
164
|
+
id: number
|
|
165
|
+
title: string
|
|
166
|
+
draft: boolean
|
|
167
|
+
authorLogin: string
|
|
168
|
+
authorId: number
|
|
169
|
+
authorIsBot: boolean
|
|
170
|
+
headRef: string | null
|
|
171
|
+
baseRef: string | null
|
|
172
|
+
updatedAt: string
|
|
173
|
+
requestedReviewerLogins: string[]
|
|
174
|
+
requestedTeamSlugs: string[]
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function parseRepo(slug: string): RepoTarget | null {
|
|
178
|
+
const [owner, repo, ...rest] = slug.trim().split('/')
|
|
179
|
+
if (owner === undefined || owner === '' || repo === undefined || repo === '' || rest.length > 0) return null
|
|
180
|
+
return { owner, repo }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function fetchOpenPrs(fetchImpl: typeof fetch, token: string, target: RepoTarget): Promise<OpenPr[]> {
|
|
184
|
+
const prs: OpenPr[] = []
|
|
185
|
+
let url: string | null = `${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}/pulls?state=open&per_page=100`
|
|
186
|
+
while (url !== null) {
|
|
187
|
+
const response = await fetchImpl(url, { headers: githubJsonHeaders(token) })
|
|
188
|
+
if (!response.ok) {
|
|
189
|
+
const body = await response.text().catch(() => '')
|
|
190
|
+
throw new Error(`GitHub pulls ${response.status}${body !== '' ? `: ${body}` : ''}`)
|
|
191
|
+
}
|
|
192
|
+
const page = (await response.json().catch(() => null)) as PrRow[] | null
|
|
193
|
+
if (page === null) throw new Error('GitHub pulls returned non-JSON')
|
|
194
|
+
for (const row of page) {
|
|
195
|
+
const parsed = parsePrRow(row)
|
|
196
|
+
if (parsed !== null) prs.push(parsed)
|
|
197
|
+
}
|
|
198
|
+
url = nextLink(response.headers.get('link'))
|
|
199
|
+
}
|
|
200
|
+
return prs
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function botAlreadyReviewed(
|
|
204
|
+
fetchImpl: typeof fetch,
|
|
205
|
+
token: string,
|
|
206
|
+
target: RepoTarget,
|
|
207
|
+
prNumber: number,
|
|
208
|
+
selfLogin: string,
|
|
209
|
+
): Promise<boolean> {
|
|
210
|
+
let url: string | null =
|
|
211
|
+
`${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}/pulls/${prNumber}/reviews?per_page=100`
|
|
212
|
+
while (url !== null) {
|
|
213
|
+
const response = await fetchImpl(url, { headers: githubJsonHeaders(token) })
|
|
214
|
+
if (!response.ok) {
|
|
215
|
+
const body = await response.text().catch(() => '')
|
|
216
|
+
throw new Error(`GitHub reviews ${response.status}${body !== '' ? `: ${body}` : ''}`)
|
|
217
|
+
}
|
|
218
|
+
const page = (await response.json().catch(() => null)) as ReviewRow[] | null
|
|
219
|
+
if (page === null) throw new Error('GitHub reviews returned non-JSON')
|
|
220
|
+
for (const row of page) {
|
|
221
|
+
const login = row.user?.login ?? null
|
|
222
|
+
if (login === null) continue
|
|
223
|
+
if (isSelfLogin(login, row.user?.type === 'Bot', selfLogin)) return true
|
|
224
|
+
}
|
|
225
|
+
url = nextLink(response.headers.get('link'))
|
|
226
|
+
}
|
|
227
|
+
return false
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isUserReviewRequestedFromSelf(pr: OpenPr, selfLogin: string, decoyLogin: string | null): boolean {
|
|
231
|
+
return pr.requestedReviewerLogins.some(
|
|
232
|
+
(login) => isSelfLogin(login, login.endsWith(BOT_LOGIN_SUFFIX), selfLogin) || login === decoyLogin,
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isSelfAuthored(pr: OpenPr, selfLogin: string, decoyLogin: string | null): boolean {
|
|
237
|
+
return isSelfLogin(pr.authorLogin, pr.authorIsBot, selfLogin) || pr.authorLogin === decoyLogin
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Mirrors review-state.ts isSelfReviewer: a GitHub App's REST login is
|
|
241
|
+
// `slug[bot]`, so the `[bot]` suffix-strip comparison is gated on the actor
|
|
242
|
+
// actually being a Bot to avoid attributing a human who owns the bare slug.
|
|
243
|
+
function isSelfLogin(login: string, isBot: boolean, selfLogin: string): boolean {
|
|
244
|
+
if (isBot) return normalizeBotLogin(login) === normalizeBotLogin(selfLogin)
|
|
245
|
+
return login === selfLogin
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function normalizeBotLogin(login: string): string {
|
|
249
|
+
return login.endsWith(BOT_LOGIN_SUFFIX) ? login.slice(0, -BOT_LOGIN_SUFFIX.length) : login
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// A GitHub App actor's REST login is `slug[bot]`; the decoy account an operator
|
|
253
|
+
// requests for App-auth reviews is the bare slug. PAT auth has no decoy.
|
|
254
|
+
function resolveDecoyLogin(selfLogin: string, authType: 'pat' | 'app'): string | null {
|
|
255
|
+
if (authType !== 'app') return null
|
|
256
|
+
if (!selfLogin.endsWith(BOT_LOGIN_SUFFIX)) return null
|
|
257
|
+
const slug = selfLogin.slice(0, -BOT_LOGIN_SUFFIX.length)
|
|
258
|
+
return slug !== '' ? slug : null
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function nextLink(linkHeader: string | null): string | null {
|
|
262
|
+
if (linkHeader === null) return null
|
|
263
|
+
for (const part of linkHeader.split(',')) {
|
|
264
|
+
const m = /<([^>]+)>;\s*rel="next"/.exec(part)
|
|
265
|
+
if (m !== null) return m[1] ?? null
|
|
266
|
+
}
|
|
267
|
+
return null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
type PrRow = {
|
|
271
|
+
number?: unknown
|
|
272
|
+
id?: unknown
|
|
273
|
+
title?: unknown
|
|
274
|
+
draft?: unknown
|
|
275
|
+
updated_at?: unknown
|
|
276
|
+
user?: { login?: unknown; id?: unknown; type?: unknown }
|
|
277
|
+
head?: { ref?: unknown }
|
|
278
|
+
base?: { ref?: unknown }
|
|
279
|
+
requested_reviewers?: Array<{ login?: unknown }>
|
|
280
|
+
requested_teams?: Array<{ slug?: unknown }>
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
type ReviewRow = { user?: { login?: string; type?: string } }
|
|
284
|
+
|
|
285
|
+
function parsePrRow(row: PrRow): OpenPr | null {
|
|
286
|
+
const number = typeof row.number === 'number' ? row.number : null
|
|
287
|
+
const id = typeof row.id === 'number' ? row.id : null
|
|
288
|
+
const authorLogin = typeof row.user?.login === 'string' ? row.user.login : null
|
|
289
|
+
if (number === null || id === null || authorLogin === null) return null
|
|
290
|
+
return {
|
|
291
|
+
number,
|
|
292
|
+
id,
|
|
293
|
+
title: typeof row.title === 'string' ? row.title : `#${number}`,
|
|
294
|
+
draft: row.draft === true,
|
|
295
|
+
authorLogin,
|
|
296
|
+
authorId: typeof row.user?.id === 'number' ? row.user.id : 0,
|
|
297
|
+
authorIsBot: row.user?.type === 'Bot',
|
|
298
|
+
headRef: typeof row.head?.ref === 'string' ? row.head.ref : null,
|
|
299
|
+
baseRef: typeof row.base?.ref === 'string' ? row.base.ref : null,
|
|
300
|
+
updatedAt: typeof row.updated_at === 'string' ? row.updated_at : '',
|
|
301
|
+
requestedReviewerLogins: (row.requested_reviewers ?? []).flatMap((r) =>
|
|
302
|
+
typeof r.login === 'string' ? [r.login] : [],
|
|
303
|
+
),
|
|
304
|
+
requestedTeamSlugs: (row.requested_teams ?? []).flatMap((t) => (typeof t.slug === 'string' ? [t.slug] : [])),
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -31,11 +31,20 @@ export function createGithubReviewStateResolver(deps: {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
const token = await deps.token({ repoSlug: `${target.owner}/${target.repo}` })
|
|
34
|
-
const reviews = await
|
|
34
|
+
const [reviews, reviewDecision] = await Promise.all([
|
|
35
|
+
fetchSelfReviews(fetchImpl, token, target, selfLogin),
|
|
36
|
+
fetchReviewDecision(fetchImpl, token, target),
|
|
37
|
+
])
|
|
35
38
|
if (!reviews.ok) return { ok: false, error: reviews.error, code: reviews.code }
|
|
39
|
+
if (!reviewDecision.ok) return { ok: false, error: reviewDecision.error, code: reviewDecision.code }
|
|
36
40
|
|
|
37
41
|
const lastDecisive = reviews.states.filter(isDecisive).at(-1) ?? null
|
|
38
|
-
return {
|
|
42
|
+
return {
|
|
43
|
+
ok: true,
|
|
44
|
+
selfBlocking: lastDecisive === 'CHANGES_REQUESTED',
|
|
45
|
+
approve,
|
|
46
|
+
...(reviewDecision.reviewDecision !== null ? { reviewDecision: reviewDecision.reviewDecision } : {}),
|
|
47
|
+
}
|
|
39
48
|
}
|
|
40
49
|
}
|
|
41
50
|
|
|
@@ -55,6 +64,12 @@ type SelfReviewsResult =
|
|
|
55
64
|
| { ok: true; states: string[] }
|
|
56
65
|
| { ok: false; error: string; code: 'not-found' | 'permission-denied' | 'transient' }
|
|
57
66
|
|
|
67
|
+
type ReviewDecision = 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED'
|
|
68
|
+
|
|
69
|
+
type ReviewDecisionResult =
|
|
70
|
+
| { ok: true; reviewDecision: ReviewDecision | null }
|
|
71
|
+
| { ok: false; error: string; code: 'not-found' | 'permission-denied' | 'transient' }
|
|
72
|
+
|
|
58
73
|
async function fetchSelfReviews(
|
|
59
74
|
fetchImpl: typeof fetch,
|
|
60
75
|
token: string,
|
|
@@ -94,6 +109,47 @@ async function fetchSelfReviews(
|
|
|
94
109
|
return { ok: true, states }
|
|
95
110
|
}
|
|
96
111
|
|
|
112
|
+
async function fetchReviewDecision(
|
|
113
|
+
fetchImpl: typeof fetch,
|
|
114
|
+
token: string,
|
|
115
|
+
target: Target,
|
|
116
|
+
): Promise<ReviewDecisionResult> {
|
|
117
|
+
let response: Response
|
|
118
|
+
try {
|
|
119
|
+
response = await fetchImpl(`${GITHUB_API_BASE}/graphql`, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: githubJsonHeaders(token),
|
|
122
|
+
body: JSON.stringify({
|
|
123
|
+
query:
|
|
124
|
+
'query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewDecision}}}',
|
|
125
|
+
variables: { owner: target.owner, repo: target.repo, number: target.prNumber },
|
|
126
|
+
}),
|
|
127
|
+
})
|
|
128
|
+
} catch (err) {
|
|
129
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err), code: 'transient' }
|
|
130
|
+
}
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
const text = await response.text().catch(() => '')
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
error: `GitHub reviewDecision ${response.status}${text !== '' ? `: ${text}` : ''}`,
|
|
136
|
+
code: classifyStatus(response.status),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const raw = (await response.json().catch(() => null)) as ReviewDecisionResponse | null
|
|
140
|
+
if (raw === null) return { ok: false, error: 'GitHub reviewDecision returned non-JSON', code: 'transient' }
|
|
141
|
+
if (Array.isArray(raw.errors) && raw.errors.length > 0) {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
error: `GitHub reviewDecision errors: ${raw.errors.map(describeGraphqlError).join('; ')}`,
|
|
145
|
+
code: 'transient',
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const value = raw.data?.repository?.pullRequest?.reviewDecision ?? null
|
|
149
|
+
if (value === null || isReviewDecision(value)) return { ok: true, reviewDecision: value }
|
|
150
|
+
return { ok: false, error: `GitHub reviewDecision returned unknown value: ${String(value)}`, code: 'transient' }
|
|
151
|
+
}
|
|
152
|
+
|
|
97
153
|
// A formal CHANGES_REQUESTED is sticky until a later APPROVED/DISMISSED; only
|
|
98
154
|
// these three states decide the block. COMMENTED and PENDING are non-deciding
|
|
99
155
|
// noise that must NOT shadow an earlier CHANGES_REQUESTED.
|
|
@@ -135,3 +191,16 @@ function classifyStatus(status: number): 'not-found' | 'permission-denied' | 'tr
|
|
|
135
191
|
}
|
|
136
192
|
|
|
137
193
|
type ReviewRow = { id?: number; state?: unknown; user?: { login?: string; type?: string } }
|
|
194
|
+
|
|
195
|
+
type ReviewDecisionResponse = {
|
|
196
|
+
data?: { repository?: { pullRequest?: { reviewDecision?: unknown } | null } | null }
|
|
197
|
+
errors?: Array<{ message?: unknown }>
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function isReviewDecision(value: unknown): value is ReviewDecision {
|
|
201
|
+
return value === 'APPROVED' || value === 'CHANGES_REQUESTED' || value === 'REVIEW_REQUIRED'
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function describeGraphqlError(error: { message?: unknown }): string {
|
|
205
|
+
return typeof error.message === 'string' ? error.message : JSON.stringify(error)
|
|
206
|
+
}
|
|
@@ -7,8 +7,8 @@ import type { InboundAttachment, InboundMessage, InboundReferenceContext } from
|
|
|
7
7
|
export type InboundDropReason = 'self_author' | 'empty_text' | 'unknown_chat' | 'pre_connect' | 'bot_message'
|
|
8
8
|
|
|
9
9
|
// LOCO message_type 71 is KakaoTalk's notification/feed channel — official
|
|
10
|
-
// accounts like "
|
|
11
|
-
// notices, system messages). These arrive in @kakao-group buckets because
|
|
10
|
+
// accounts like "KakaoTalk Customer Center" and "Kakao Account" (login alerts,
|
|
11
|
+
// security notices, system messages). These arrive in @kakao-group buckets because
|
|
12
12
|
// they aren't normal user chats, but they are not human conversation and
|
|
13
13
|
// the agent should never reply to them. Not enumerated in
|
|
14
14
|
// agent-messenger's `KAKAO_MESSAGE_TYPE` because that const only covers
|
|
@@ -436,7 +436,7 @@ export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): Kakaot
|
|
|
436
436
|
)
|
|
437
437
|
|
|
438
438
|
// Ack the message BEFORE classify/route so the sender's unread "1"
|
|
439
|
-
// (
|
|
439
|
+
// (the yellow unread badge) clears even when we drop the message (self-author,
|
|
440
440
|
// unknown chat, empty text, etc.). The receiver of a kakao adapter is
|
|
441
441
|
// expected to behave like a "read it as soon as it arrives" client —
|
|
442
442
|
// the agent has observed the bytes, so the user should see the read
|
|
@@ -729,7 +729,7 @@ function dropHint(reason: InboundDropReason): string {
|
|
|
729
729
|
case 'unknown_chat':
|
|
730
730
|
return ' (chat not in cache after refresh and provisional registration; check earlier resolver-refresh-failed warnings)'
|
|
731
731
|
case 'bot_message':
|
|
732
|
-
return ' (LOCO message_type=71 is KakaoTalk notification/feed; official accounts like
|
|
732
|
+
return ' (LOCO message_type=71 is KakaoTalk notification/feed; official accounts like KakaoTalk Customer Center / Kakao Account / login alerts)'
|
|
733
733
|
case 'empty_text':
|
|
734
734
|
case 'pre_connect':
|
|
735
735
|
case 'self_author':
|
|
@@ -86,7 +86,7 @@ export function classifyInbound(
|
|
|
86
86
|
// without any new config surface.
|
|
87
87
|
const hasGroupMention = GROUP_MENTION_PATTERN.test(rawText)
|
|
88
88
|
const isBotMention = hasGroupMention || rawText.includes(`<@${context.botUserId}>`)
|
|
89
|
-
// Top-level alias addressing (e.g. "
|
|
89
|
+
// Top-level alias addressing (e.g. "Momo!" / "@Momo" by name) is engagement-equivalent
|
|
90
90
|
// to a `<@bot>` mention (see engagement.ts: alias is unconditional and
|
|
91
91
|
// ranks alongside explicit triggers). Anchor `thread` on the inbound
|
|
92
92
|
// ts in that case too, so the bot's reply threads under the user's
|
|
@@ -1213,6 +1213,7 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
|
|
|
1213
1213
|
options.router.registerReaction('slack-bot', reactionCallback)
|
|
1214
1214
|
options.router.registerRemoveReaction('slack-bot', removeReactionCallback)
|
|
1215
1215
|
options.router.registerTyping('slack-bot', typingCallback)
|
|
1216
|
+
options.router.setTypingCapability('slack-bot', true)
|
|
1216
1217
|
options.router.registerChannelNameResolver('slack-bot', channelResolver)
|
|
1217
1218
|
options.router.registerSelfIdentity('slack-bot', selfIdentityResolver)
|
|
1218
1219
|
options.router.registerHistory('slack-bot', historyCallback)
|
|
@@ -1230,6 +1231,7 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
|
|
|
1230
1231
|
options.router.unregisterReaction('slack-bot', reactionCallback)
|
|
1231
1232
|
options.router.unregisterRemoveReaction('slack-bot', removeReactionCallback)
|
|
1232
1233
|
options.router.unregisterTyping('slack-bot', typingCallback)
|
|
1234
|
+
options.router.setTypingCapability('slack-bot', false)
|
|
1233
1235
|
options.router.unregisterChannelNameResolver('slack-bot', channelResolver)
|
|
1234
1236
|
options.router.unregisterSelfIdentity('slack-bot', selfIdentityResolver)
|
|
1235
1237
|
options.router.unregisterHistory('slack-bot', historyCallback)
|
|
@@ -1251,6 +1253,7 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
|
|
|
1251
1253
|
options.router.unregisterReaction('slack-bot', reactionCallback)
|
|
1252
1254
|
options.router.unregisterRemoveReaction('slack-bot', removeReactionCallback)
|
|
1253
1255
|
options.router.unregisterTyping('slack-bot', typingCallback)
|
|
1256
|
+
options.router.setTypingCapability('slack-bot', false)
|
|
1254
1257
|
options.router.unregisterChannelNameResolver('slack-bot', channelResolver)
|
|
1255
1258
|
options.router.unregisterSelfIdentity('slack-bot', selfIdentityResolver)
|
|
1256
1259
|
options.router.unregisterHistory('slack-bot', historyCallback)
|
|
@@ -529,6 +529,7 @@ export function createTelegramBotAdapter(options: TelegramBotAdapterOptions): Te
|
|
|
529
529
|
|
|
530
530
|
options.router.registerOutbound('telegram-bot', outboundCallback)
|
|
531
531
|
options.router.registerTyping('telegram-bot', typingCallback)
|
|
532
|
+
options.router.setTypingCapability('telegram-bot', true)
|
|
532
533
|
options.router.registerChannelNameResolver('telegram-bot', channelResolver)
|
|
533
534
|
options.router.registerSelfIdentity('telegram-bot', selfIdentityResolver)
|
|
534
535
|
options.router.registerFetchAttachment('telegram-bot', fetchAttachmentCallback)
|
|
@@ -537,6 +538,7 @@ export function createTelegramBotAdapter(options: TelegramBotAdapterOptions): Te
|
|
|
537
538
|
const rollbackStart = (reason: string, cause: Error): never => {
|
|
538
539
|
options.router.unregisterOutbound('telegram-bot', outboundCallback)
|
|
539
540
|
options.router.unregisterTyping('telegram-bot', typingCallback)
|
|
541
|
+
options.router.setTypingCapability('telegram-bot', false)
|
|
540
542
|
options.router.unregisterChannelNameResolver('telegram-bot', channelResolver)
|
|
541
543
|
options.router.unregisterSelfIdentity('telegram-bot', selfIdentityResolver)
|
|
542
544
|
options.router.unregisterFetchAttachment('telegram-bot', fetchAttachmentCallback)
|
|
@@ -565,6 +567,7 @@ export function createTelegramBotAdapter(options: TelegramBotAdapterOptions): Te
|
|
|
565
567
|
started = false
|
|
566
568
|
options.router.unregisterOutbound('telegram-bot', outboundCallback)
|
|
567
569
|
options.router.unregisterTyping('telegram-bot', typingCallback)
|
|
570
|
+
options.router.setTypingCapability('telegram-bot', false)
|
|
568
571
|
options.router.unregisterChannelNameResolver('telegram-bot', channelResolver)
|
|
569
572
|
options.router.unregisterSelfIdentity('telegram-bot', selfIdentityResolver)
|
|
570
573
|
options.router.unregisterFetchAttachment('telegram-bot', fetchAttachmentCallback)
|
|
@@ -89,6 +89,8 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
|
|
|
89
89
|
if (config.trigger.includes('mention') && message.isBotMention) return 'engage'
|
|
90
90
|
if (config.trigger.includes('reply') && message.replyToBotMessageId !== null) return 'engage'
|
|
91
91
|
|
|
92
|
+
const explicitOnly = message.suppressSticky === true
|
|
93
|
+
|
|
92
94
|
// Multi-human pre-sticky target check. In a busy group the conversational
|
|
93
95
|
// target shifts every message: the author we're mid-exchange with (and hold
|
|
94
96
|
// a sticky credit for) may, on THIS turn, structurally address a third party
|
|
@@ -112,12 +114,13 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
|
|
|
112
114
|
// The `!matchesAnyAlias` guard preserves the ladder invariant "explicit
|
|
113
115
|
// address to us beats structural targeting of others": a message that names
|
|
114
116
|
// us by alias engages on the alias rule below even when it ALSO tags a third
|
|
115
|
-
// party (the "
|
|
117
|
+
// party (the "Toto, Lala, both take a look" multi-bot case), so we must not pre-empt
|
|
116
118
|
// it here. We only step aside for a credited author whose message is aimed
|
|
117
119
|
// PURELY elsewhere.
|
|
118
120
|
if (
|
|
119
121
|
effectiveHumans > 1 &&
|
|
120
122
|
config.stickiness !== 'off' &&
|
|
123
|
+
!explicitOnly &&
|
|
121
124
|
!matchesAnyAlias(message.text, selfAliases) &&
|
|
122
125
|
targetsSomeoneElse(message, participants, botInThread)
|
|
123
126
|
) {
|
|
@@ -134,7 +137,9 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
|
|
|
134
137
|
// groups wholesale (the prior approach) dropped genuine follow-ups outright;
|
|
135
138
|
// the pre-check above is narrower — it only steps aside when the message is
|
|
136
139
|
// STRUCTURALLY addressed elsewhere, leaving plain follow-ups engaged.
|
|
137
|
-
|
|
140
|
+
// GitHub review-thread traffic must not spend content-blind sticky credit
|
|
141
|
+
// unless the bot was explicitly addressed.
|
|
142
|
+
if (!explicitOnly && config.stickiness !== 'off' && ledger.consume(key, message.authorId, now)) {
|
|
138
143
|
return 'engage'
|
|
139
144
|
}
|
|
140
145
|
|
|
@@ -143,12 +148,12 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
|
|
|
143
148
|
// same priority as an explicit mention — operators add aliases
|
|
144
149
|
// precisely because they expect the bot to respond when called by
|
|
145
150
|
// name. Suppression on `mentionsOthers` would defeat the point: the
|
|
146
|
-
// user can address two bots by name in one message ("
|
|
147
|
-
//
|
|
151
|
+
// user can address two bots by name in one message ("Toto, Lala, both
|
|
152
|
+
// take a look") and both should engage. Each bot only knows its own
|
|
148
153
|
// aliases, so cross-bot suppression isn't possible at this layer
|
|
149
154
|
// anyway — the router-side peer-name suppression in the solo-human
|
|
150
155
|
// fallback handles that case (follow-up).
|
|
151
|
-
if (matchesAnyAlias(message.text, selfAliases)) return 'engage'
|
|
156
|
+
if (!explicitOnly && matchesAnyAlias(message.text, selfAliases)) return 'engage'
|
|
152
157
|
|
|
153
158
|
// Solo-human fallback: the strict mention/reply/dm gate keeps the bot
|
|
154
159
|
// quiet in multi-human conversations, but in a 1-human channel that
|
|
@@ -176,8 +181,8 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
|
|
|
176
181
|
// who don't want to type `@bot` in their own DM-like channel; peer bots
|
|
177
182
|
// have no such ergonomic excuse. Letting peer bots ride the fallback
|
|
178
183
|
// produced bot-to-bot conversations in 1-human-N-bot channels (observed:
|
|
179
|
-
//
|
|
180
|
-
// "
|
|
184
|
+
// Momo and Kiki introducing themselves to each other after a single
|
|
185
|
+
// "hey folks" from the human, then continuing to address each other for
|
|
181
186
|
// ~6 turns). The router's loop guard only trips after 5 consecutive
|
|
182
187
|
// peer engagements, which is too late to prevent the embarrassment.
|
|
183
188
|
//
|