typeclaw 0.20.0 → 0.22.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 +2 -1
- package/src/agent/index.ts +55 -1
- package/src/agent/loop-guard.ts +180 -53
- package/src/agent/restart/index.ts +101 -0
- package/src/agent/tools/restart.ts +23 -52
- package/src/bundled-plugins/bun-hygiene/README.md +82 -0
- package/src/bundled-plugins/bun-hygiene/index.ts +11 -0
- package/src/bundled-plugins/bun-hygiene/policy.ts +318 -0
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +98 -6
- package/src/bundled-plugins/github-cli-auth/graphql-auth-nudge.ts +80 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +7 -0
- package/src/bundled-plugins/memory/memory-logger.ts +6 -2
- package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
- package/src/channels/adapters/discord-bot-classify.ts +8 -2
- package/src/channels/adapters/discord-bot.ts +29 -2
- package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
- package/src/channels/adapters/github/inbound.ts +92 -1
- package/src/channels/adapters/github/index.ts +12 -1
- package/src/channels/adapters/github/reactions.ts +138 -4
- package/src/channels/adapters/slack-bot-classify.ts +2 -2
- package/src/channels/adapters/slack-bot.ts +129 -7
- package/src/channels/engagement.ts +71 -31
- package/src/channels/manager.ts +8 -0
- package/src/channels/router.ts +180 -25
- package/src/channels/schema.ts +18 -0
- package/src/channels/types.ts +16 -1
- package/src/cli/builtins.ts +1 -0
- package/src/cli/dreams.ts +148 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/inspect.ts +2 -1
- package/src/cli/ui.ts +34 -0
- package/src/commands/index.ts +5 -2
- package/src/config/config.ts +89 -0
- package/src/dreams/git.ts +85 -0
- package/src/dreams/index.ts +134 -0
- package/src/dreams/parse.ts +224 -0
- package/src/dreams/render.ts +155 -0
- package/src/dreams/types.ts +50 -0
- package/src/mcp/catalog.ts +29 -0
- package/src/mcp/client.ts +236 -0
- package/src/mcp/index.ts +25 -0
- package/src/mcp/manager.ts +156 -0
- package/src/mcp/tools.ts +190 -0
- package/src/permissions/builtins.ts +9 -0
- package/src/reload/format.ts +14 -0
- package/src/reload/index.ts +1 -0
- package/src/run/bundled-plugins.ts +7 -0
- package/src/run/channel-session-factory.ts +3 -0
- package/src/run/index.ts +38 -1
- package/src/server/command-runner.ts +5 -0
- package/src/server/index.ts +53 -0
- package/src/shared/protocol.ts +2 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +86 -22
- package/src/tui/index.ts +70 -18
- package/typeclaw.schema.json +82 -0
|
@@ -2,6 +2,7 @@ import { TYPECLAW_INTERNAL_BASH_ENV } from '@/agent/plugin-tools'
|
|
|
2
2
|
import { definePlugin } from '@/plugin'
|
|
3
3
|
|
|
4
4
|
import { analyzeGhCommand } from './gh-command'
|
|
5
|
+
import { checkGraphqlAuthNudge } from './graphql-auth-nudge'
|
|
5
6
|
import { classifyGhToken } from './token-class'
|
|
6
7
|
|
|
7
8
|
export default definePlugin({
|
|
@@ -34,8 +35,14 @@ export default definePlugin({
|
|
|
34
35
|
// --setenv by the bash wrapper) so the token never enters the command
|
|
35
36
|
// string, where it could leak through logs or later hooks.
|
|
36
37
|
event.args[TYPECLAW_INTERNAL_BASH_ENV] = { GH_TOKEN: result.token }
|
|
38
|
+
// graphql consumed `-R/--repo` as a mint hint; `gh api` rejects it, so
|
|
39
|
+
// run the command with the flag stripped (token still rides in env).
|
|
40
|
+
if (decision.rewrittenCommand !== undefined) event.args.command = decision.rewrittenCommand
|
|
37
41
|
return
|
|
38
42
|
},
|
|
43
|
+
'tool.after': async (event) => {
|
|
44
|
+
checkGraphqlAuthNudge({ tool: event.tool, result: event.result })
|
|
45
|
+
},
|
|
39
46
|
},
|
|
40
47
|
}
|
|
41
48
|
},
|
|
@@ -81,9 +81,11 @@ Session transcripts are JSONL files where each line is an entry with an \`id\` f
|
|
|
81
81
|
Typical flow with a watermark:
|
|
82
82
|
|
|
83
83
|
1. \`find_entry(path=<transcript>, entryId=<watermark>)\` → returns \`line=N, totalLines=T, offset=N+1\`.
|
|
84
|
-
2. \`read(path=<transcript>, offset=N+1)\` → returns the chunk starting AT the first unread entry. Repeat with the next offset until the read tool
|
|
84
|
+
2. \`read(path=<transcript>, offset=N+1)\` → returns the chunk starting AT the first unread entry. Repeat with the next offset until you reach the end of the file. \`find_entry\` already told you \`totalLines=T\`: once a \`read\` has returned line T (or the read tool reports no continuation), you have reached the end of the transcript. Stop reading.
|
|
85
85
|
3. As you read, track the most recent \`id\` you see. That is your new watermark value — pass it as \`latestEntryId\` on the final \`append\` call, or to the watermark-advance tool when there are zero fragments.
|
|
86
86
|
|
|
87
|
+
**Reading is bounded — a finite transcript takes a finite number of reads.** \`find_entry\` gives you \`totalLines=T\` up front, so you always know the last line. Each \`read\` returns a slice and an offset to continue; advance the offset forward each time. Once you have read line T, or a \`read\` returns no new content (an empty chunk, or the same slice you already saw, or no continuation offset), you are at the end. Do NOT re-read the same offset, and do NOT keep calling \`read\` hoping more will appear — nothing more will. A read that returns nothing new is the end-of-file signal, not a transient error to retry. Re-reading past the end produces no new information and wastes the entire run; treat the first no-new-content read as "done reading" and move to your fragment decision.
|
|
88
|
+
|
|
87
89
|
Never write the same watermark id you were given as input. If the transcript has no new entries past the watermark, evaluate the entries you can see, then advance the watermark to the latest \`id\` in the transcript (which is on line \`totalLines\` from \`find_entry\`'s reply). The whole point of the watermark is to move forward each run.
|
|
88
90
|
|
|
89
91
|
# Capture philosophy: when in doubt, SKIP
|
|
@@ -202,7 +204,9 @@ When you evaluated the transcript but found nothing worth a fragment, call the w
|
|
|
202
204
|
|
|
203
205
|
# Stopping
|
|
204
206
|
|
|
205
|
-
|
|
207
|
+
You are done the moment BOTH are true: (1) you have read to the end of the transcript (reached \`totalLines\` from \`find_entry\`, or a \`read\` returned no new content), and (2) you have either written your fragment(s) with the final \`latestEntryId\`, or advanced the watermark for the zero-fragment case. When both hold, simply stop. There is no completion message to emit.
|
|
208
|
+
|
|
209
|
+
Do not loop. The hard stop is \`totalLines\`: a long transcript may legitimately need many \`read\` chunks to reach it, and that is fine as long as each \`read\` advances the offset toward \`totalLines\`. What is NOT fine is re-reading without progress. If a \`read\` returns no new content, returns the same slice you already saw, or your offset stops advancing, you are at the end — stop reading immediately and proceed to your fragment decision. A transcript has a fixed length; re-reading the same offset cannot surface content that is not there. The single most expensive failure mode for this subagent is re-reading the same file in a cycle instead of recognizing end-of-file and stopping.`
|
|
206
210
|
|
|
207
211
|
function buildInitialPrompt(payload: MemoryLoggerPayload, streamFile: string, watermark: string | null): string {
|
|
208
212
|
const lines: string[] = [
|
|
@@ -64,6 +64,14 @@ Prioritize in this order:
|
|
|
64
64
|
- **request-changes** — At least one blocker, OR a load-bearing concern that needs an answer before this lands.
|
|
65
65
|
- **comment** — Mixed signal: useful observations without a clear approve/reject. Common on large refactors where you reviewed part of the change, or on early-draft PRs where the author asked for direction more than approval.
|
|
66
66
|
|
|
67
|
+
### Re-reviews must re-decide, not observe
|
|
68
|
+
|
|
69
|
+
When the payload tells you this is a **re-review** — you (or this agent) previously requested changes on this PR and the author has pushed fixes and asked again — your verdict's whole purpose is to **re-decide the blocking state**, so:
|
|
70
|
+
|
|
71
|
+
- Return **approve** if the blockers that drove the prior \`request-changes\` are resolved (leftover nits do not block — \`approve\` with inline nits is correct).
|
|
72
|
+
- Return **request-changes** if any blocker remains or a new one appeared.
|
|
73
|
+
- **Do NOT return \`comment\` on a re-review.** \`comment\` is for ambiguous partial reviews with no accept/reject signal; a re-review is the opposite — it is precisely an accept/reject decision. A \`comment\` verdict here leaves the PR's \`REQUEST_CHANGES\` state stuck (a plain comment does not clear it on GitHub), which is the exact failure a re-review exists to resolve. The only escape hatch is the same one that always applies: if you genuinely cannot reach the diff or the prior context, return one \`blocker\` finding stating what you need and a \`comment\` verdict — but a reachable, reviewable re-review must end in \`approve\` or \`request-changes\`.
|
|
74
|
+
|
|
67
75
|
## Line-anchor every finding
|
|
68
76
|
|
|
69
77
|
Code review is line-level work, and your findings are meant to land as **inline comments on the exact lines they describe**. The parent agent posts them that way — it reads the \`location\` on each \`<finding>\` and attaches your \`<issue>\`/\`<evidence>\`/\`<suggestion>\` to that line. A finding with no line anchor cannot be posted inline; the parent can only fold it into a top-level summary, which strips the one thing that made it actionable.
|
|
@@ -141,7 +141,13 @@ function splitInbound(event: DiscordGatewayMessageCreateEvent): SplitInbound {
|
|
|
141
141
|
return { text, attachments }
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
|
|
144
|
+
export type DiscordMediaCarrier = {
|
|
145
|
+
attachments?: DiscordFile[]
|
|
146
|
+
embeds?: DiscordGatewayEmbed[]
|
|
147
|
+
sticker_items?: DiscordGatewayStickerItem[]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function describeDiscordMedia(event: DiscordMediaCarrier): InboundAttachment[] {
|
|
145
151
|
return [
|
|
146
152
|
...(event.attachments ?? []).map(describeAttachment),
|
|
147
153
|
...(event.embeds ?? []).map(describeEmbed),
|
|
@@ -167,7 +173,7 @@ function describeSticker(sticker: DiscordGatewayStickerItem): Omit<InboundAttach
|
|
|
167
173
|
return { kind: 'sticker', ref: '', filename: sticker.name }
|
|
168
174
|
}
|
|
169
175
|
|
|
170
|
-
function renderPlaceholder(attachment: InboundAttachment): string {
|
|
176
|
+
export function renderPlaceholder(attachment: InboundAttachment): string {
|
|
171
177
|
const parts: string[] = [`Discord attachment #${attachment.id}: ${attachment.kind}`]
|
|
172
178
|
if (attachment.mimetype !== undefined) parts.push(attachment.mimetype)
|
|
173
179
|
if (attachment.filename !== undefined) parts.push(`name=${attachment.filename}`)
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { DiscordBotClient, DiscordBotListener } from 'agent-messenger/discordbot'
|
|
2
2
|
import {
|
|
3
3
|
DiscordIntent,
|
|
4
|
+
type DiscordFile,
|
|
5
|
+
type DiscordGatewayEmbed,
|
|
4
6
|
type DiscordGatewayInteractionEvent,
|
|
5
7
|
type DiscordGatewayMessageCreateEvent,
|
|
8
|
+
type DiscordGatewayStickerItem,
|
|
6
9
|
} from 'agent-messenger/discordbot'
|
|
7
10
|
|
|
8
11
|
import {
|
|
@@ -29,7 +32,12 @@ import type {
|
|
|
29
32
|
} from '@/channels/types'
|
|
30
33
|
|
|
31
34
|
import { createDiscordChannelResolver } from './discord-bot-channel-resolver'
|
|
32
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
classifyInbound,
|
|
37
|
+
describeDiscordMedia,
|
|
38
|
+
type InboundDropReason,
|
|
39
|
+
renderPlaceholder,
|
|
40
|
+
} from './discord-bot-classify'
|
|
33
41
|
import {
|
|
34
42
|
ackInteraction,
|
|
35
43
|
parseInteractionAsCommand,
|
|
@@ -44,6 +52,8 @@ import {
|
|
|
44
52
|
const SLASH_COMMANDS: readonly DiscordCommandDeclaration[] = [
|
|
45
53
|
{ name: 'help', description: 'List available commands' },
|
|
46
54
|
{ name: 'stop', description: 'Abort the current turn in this channel' },
|
|
55
|
+
{ name: 'reload', description: 'Reload typeclaw config and subsystems from disk' },
|
|
56
|
+
{ name: 'restart', description: 'Restart the typeclaw container' },
|
|
47
57
|
]
|
|
48
58
|
const SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set(SLASH_COMMANDS.map((c) => c.name))
|
|
49
59
|
|
|
@@ -487,6 +497,9 @@ type DiscordRawHistoryMessage = {
|
|
|
487
497
|
content: string
|
|
488
498
|
timestamp: string
|
|
489
499
|
message_reference?: { message_id?: string }
|
|
500
|
+
attachments?: DiscordFile[]
|
|
501
|
+
embeds?: DiscordGatewayEmbed[]
|
|
502
|
+
sticker_items?: DiscordGatewayStickerItem[]
|
|
490
503
|
}
|
|
491
504
|
|
|
492
505
|
// Discord treats threads as separate channels with their own snowflake ids,
|
|
@@ -551,14 +564,28 @@ export function createDiscordHistoryCallback(deps: {
|
|
|
551
564
|
function mapDiscordMessage(msg: DiscordRawHistoryMessage, botUserId: string | null): ChannelHistoryMessage {
|
|
552
565
|
const isBot = msg.author.bot === true || (botUserId !== null && msg.author.id === botUserId)
|
|
553
566
|
const ts = Date.parse(msg.timestamp)
|
|
567
|
+
// The REST history fetch bypasses the inbound classifier, so attachments,
|
|
568
|
+
// embeds, and stickers on already-posted messages (e.g. an image on a thread
|
|
569
|
+
// root the agent is later @-mentioned under) must be mapped here too —
|
|
570
|
+
// otherwise they are silently dropped and look_at_channel_attachment can
|
|
571
|
+
// never resolve them. Mirror the classifier's splitInbound: bake placeholders
|
|
572
|
+
// into text and carry the structured attachments so the router can resolve ids.
|
|
573
|
+
const attachments = describeDiscordMedia(msg)
|
|
574
|
+
const text =
|
|
575
|
+
attachments.length === 0
|
|
576
|
+
? msg.content
|
|
577
|
+
: msg.content === ''
|
|
578
|
+
? attachments.map(renderPlaceholder).join('\n')
|
|
579
|
+
: `${msg.content}\n${attachments.map(renderPlaceholder).join('\n')}`
|
|
554
580
|
return {
|
|
555
581
|
externalMessageId: msg.id,
|
|
556
582
|
authorId: msg.author.id,
|
|
557
583
|
authorName: msg.author.global_name ?? msg.author.username ?? msg.author.id,
|
|
558
|
-
text
|
|
584
|
+
text,
|
|
559
585
|
ts: Number.isFinite(ts) ? ts : 0,
|
|
560
586
|
isBot,
|
|
561
587
|
replyToBotMessageId: msg.message_reference?.message_id ?? null,
|
|
588
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
562
589
|
}
|
|
563
590
|
}
|
|
564
591
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
2
|
+
|
|
3
|
+
// `absent` separates "GitHub says the reviewer is already not requested"
|
|
4
|
+
// (404/422 — never on the list, already removed, or invalid for the repo) from
|
|
5
|
+
// `failed` ("couldn't reach GitHub"), so callers warn only on the latter.
|
|
6
|
+
export type RemoveRequestedReviewerResult =
|
|
7
|
+
| { kind: 'removed'; status: number }
|
|
8
|
+
| { kind: 'absent'; status: number; message: string }
|
|
9
|
+
| { kind: 'failed'; status?: number; reason: string }
|
|
10
|
+
|
|
11
|
+
export async function removeRequestedReviewer(params: {
|
|
12
|
+
fetchImpl: typeof fetch
|
|
13
|
+
token: string
|
|
14
|
+
owner: string
|
|
15
|
+
repo: string
|
|
16
|
+
pullNumber: number
|
|
17
|
+
reviewerLogin: string
|
|
18
|
+
}): Promise<RemoveRequestedReviewerResult> {
|
|
19
|
+
const url = `${GITHUB_API_BASE}/repos/${params.owner}/${params.repo}/pulls/${params.pullNumber}/requested_reviewers`
|
|
20
|
+
try {
|
|
21
|
+
const response = await params.fetchImpl(url, {
|
|
22
|
+
method: 'DELETE',
|
|
23
|
+
headers: githubJsonHeaders(params.token),
|
|
24
|
+
body: JSON.stringify({ reviewers: [params.reviewerLogin] }),
|
|
25
|
+
})
|
|
26
|
+
if (response.ok) return { kind: 'removed', status: response.status }
|
|
27
|
+
const message = await response.text().catch(() => '')
|
|
28
|
+
// 404 (PR/reviewer not found) and 422 (reviewer not currently requested,
|
|
29
|
+
// or not a valid reviewer for this repo) mean there is nothing to remove —
|
|
30
|
+
// the desired end state already holds. Everything else (401/403 auth,
|
|
31
|
+
// 429 rate, 5xx) is a real failure worth surfacing.
|
|
32
|
+
if (response.status === 404 || response.status === 422) {
|
|
33
|
+
return { kind: 'absent', status: response.status, message }
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
kind: 'failed',
|
|
37
|
+
status: response.status,
|
|
38
|
+
reason: `GitHub API ${response.status}${message !== '' ? `: ${message}` : ''}`,
|
|
39
|
+
}
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return { kind: 'failed', reason: err instanceof Error ? err.message : String(err) }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -2,6 +2,8 @@ import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
|
2
2
|
|
|
3
3
|
import type { InboundMessage } from '@/channels/types'
|
|
4
4
|
|
|
5
|
+
import type { GithubAuthContext } from './auth'
|
|
6
|
+
import { removeRequestedReviewer } from './decoy-reviewer'
|
|
5
7
|
import type { DeliveryDedup } from './dedup'
|
|
6
8
|
import { isGithubEventAllowed } from './event-allowlist'
|
|
7
9
|
import { encodeGithubReactionRef, type GithubReactionTarget } from './reactions'
|
|
@@ -17,12 +19,23 @@ export type GithubWebhookHandlerOptions = {
|
|
|
17
19
|
// Defaults to 'pat' when omitted. In 'app' mode classifyReviewRequest also
|
|
18
20
|
// matches the App's decoy reviewer login; see resolveDecoyReviewerLogin.
|
|
19
21
|
authType?: () => 'pat' | 'app'
|
|
22
|
+
// Defaults to true when omitted. When it returns false, every inbound carries
|
|
23
|
+
// an appended operator-policy note telling the agent not to submit an APPROVE
|
|
24
|
+
// review; the github skill keys off that note to downgrade approve→COMMENT.
|
|
25
|
+
allowApprove?: () => boolean
|
|
20
26
|
route: (message: InboundMessage) => void
|
|
21
27
|
logger: GithubInboundLogger
|
|
22
28
|
// Optional: resolves whether the bot is a member of the given team. When
|
|
23
29
|
// omitted, team-reviewer requests are silently dropped (the v1 fallback
|
|
24
30
|
// behavior). The adapter wires this in production; tests inject a fake.
|
|
25
31
|
isBotInTeam?: (input: { org: string; slug: string; login: string }) => Promise<boolean>
|
|
32
|
+
// App-auth only: mints a repo-scoped token used to drop the decoy reviewer
|
|
33
|
+
// once the bot's own review lands. Omitted under PAT auth (no decoy exists).
|
|
34
|
+
authToken?: (context?: GithubAuthContext) => Promise<string>
|
|
35
|
+
// Schedules the decoy-drop off the webhook ACK path so the 200 stays fast.
|
|
36
|
+
// Defaults to fire-and-forget; tests inject a recorder to await the task.
|
|
37
|
+
scheduleBackgroundTask?: (task: () => Promise<void>) => void
|
|
38
|
+
fetchImpl?: typeof fetch
|
|
26
39
|
}
|
|
27
40
|
|
|
28
41
|
export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions): (req: Request) => Promise<Response> {
|
|
@@ -51,6 +64,7 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
|
|
|
51
64
|
const selfLogin = options.selfLogin()
|
|
52
65
|
const author = readAuthor(event, payload)
|
|
53
66
|
if (author !== null && isSelfAuthor(author, selfId, selfLogin)) {
|
|
67
|
+
maybeScheduleDecoyReviewerDrop({ event, action, payload, selfLogin, options })
|
|
54
68
|
options.logger.info(
|
|
55
69
|
`[github] dropped self-authored ${event}${action !== null ? `.${action}` : ''} from @${author.login}`,
|
|
56
70
|
)
|
|
@@ -65,11 +79,88 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
|
|
|
65
79
|
if (classified === null) return ok()
|
|
66
80
|
|
|
67
81
|
if (delivery !== '') options.dedup.add(delivery)
|
|
68
|
-
options.route(classified)
|
|
82
|
+
options.route(withApprovalPolicy(classified, options.allowApprove?.() ?? true))
|
|
69
83
|
return ok()
|
|
70
84
|
}
|
|
71
85
|
}
|
|
72
86
|
|
|
87
|
+
export const PR_APPROVAL_DISABLED_NOTE =
|
|
88
|
+
'Operator policy: PR approval is disabled for this agent ' +
|
|
89
|
+
'(`channels.github.review.approve: false`). If you review a PR and the ' +
|
|
90
|
+
'verdict is `approve`, submit a `COMMENT` review instead of `APPROVE` — post ' +
|
|
91
|
+
'the findings, but never formally approve.'
|
|
92
|
+
|
|
93
|
+
// Gating PR approval lives here (inbound text), not at the bash layer: the
|
|
94
|
+
// review is posted via `gh api --input <file>`, so the `event: APPROVE` value
|
|
95
|
+
// sits in a temp file the gh-cli-auth command interceptor never inspects. The
|
|
96
|
+
// note rides on every inbound (cheap: one line, only when an operator has
|
|
97
|
+
// opted out) so it reaches the agent for both webhook review requests and
|
|
98
|
+
// plain-language "@bot review this" asks, which arrive on arbitrary inbounds.
|
|
99
|
+
function withApprovalPolicy(message: InboundMessage, allowApprove: boolean): InboundMessage {
|
|
100
|
+
if (allowApprove) return message
|
|
101
|
+
const text = message.text === '' ? PR_APPROVAL_DISABLED_NOTE : `${message.text}\n\n${PR_APPROVAL_DISABLED_NOTE}`
|
|
102
|
+
return { ...message, text }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// GitHub auto-records the App as a reviewer the moment its review posts, but
|
|
106
|
+
// leaves the decoy user pinned as a perpetual "review requested". When the bot
|
|
107
|
+
// drops its own review (the self-authored event we're about to discard), fire a
|
|
108
|
+
// background DELETE to remove the decoy. The DELETE is authenticated as the App,
|
|
109
|
+
// so the resulting review_request_removed webhook has the bot actor as sender
|
|
110
|
+
// and is dropped by classifyReviewRequest's self-loop guard — no fresh session.
|
|
111
|
+
function maybeScheduleDecoyReviewerDrop(input: {
|
|
112
|
+
event: string
|
|
113
|
+
action: string | null
|
|
114
|
+
payload: Record<string, unknown>
|
|
115
|
+
selfLogin: string | null
|
|
116
|
+
options: GithubWebhookHandlerOptions
|
|
117
|
+
}): void {
|
|
118
|
+
const { event, action, payload, selfLogin, options } = input
|
|
119
|
+
if (event !== 'pull_request_review' || action !== 'submitted') return
|
|
120
|
+
if (selfLogin === null) return
|
|
121
|
+
const authToken = options.authToken
|
|
122
|
+
if (authToken === undefined) return
|
|
123
|
+
if ((options.authType?.() ?? 'pat') !== 'app') return
|
|
124
|
+
const decoyLogin = resolveDecoyReviewerLogin(selfLogin, 'app')
|
|
125
|
+
if (decoyLogin === null) return
|
|
126
|
+
|
|
127
|
+
const repository = readRepository(payload)
|
|
128
|
+
const pr = readRecord(payload.pull_request)
|
|
129
|
+
const pullNumber = readNumber(pr, 'number')
|
|
130
|
+
if (repository === null || pullNumber === null) return
|
|
131
|
+
|
|
132
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
133
|
+
const schedule = options.scheduleBackgroundTask ?? defaultScheduleBackgroundTask
|
|
134
|
+
const target = `${repository.owner}/${repository.name}#${pullNumber}`
|
|
135
|
+
schedule(async () => {
|
|
136
|
+
// authToken can throw (installation lookup / token mint), and a thrown
|
|
137
|
+
// failure must still warn — the default scheduler swallows rejections, so
|
|
138
|
+
// catching here is the only place the failure is observable.
|
|
139
|
+
try {
|
|
140
|
+
const token = await authToken({ repoSlug: `${repository.owner}/${repository.name}` })
|
|
141
|
+
const result = await removeRequestedReviewer({
|
|
142
|
+
fetchImpl,
|
|
143
|
+
token,
|
|
144
|
+
owner: repository.owner,
|
|
145
|
+
repo: repository.name,
|
|
146
|
+
pullNumber,
|
|
147
|
+
reviewerLogin: decoyLogin,
|
|
148
|
+
})
|
|
149
|
+
if (result.kind === 'failed') {
|
|
150
|
+
options.logger.warn(`[github] failed to drop decoy reviewer @${decoyLogin} from ${target}: ${result.reason}`)
|
|
151
|
+
}
|
|
152
|
+
} catch (err) {
|
|
153
|
+
options.logger.warn(
|
|
154
|
+
`[github] failed to drop decoy reviewer @${decoyLogin} from ${target}: ${err instanceof Error ? err.message : String(err)}`,
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function defaultScheduleBackgroundTask(task: () => Promise<void>): void {
|
|
161
|
+
void task().catch(() => {})
|
|
162
|
+
}
|
|
163
|
+
|
|
73
164
|
export async function verifySignature(body: string, secret: string, sigHeader: string): Promise<boolean> {
|
|
74
165
|
const expected = `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`
|
|
75
166
|
const a = Buffer.from(expected)
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
buildPermissionGuidance,
|
|
20
20
|
parseListHooksPermissionStatus,
|
|
21
21
|
} from './permission-guidance'
|
|
22
|
-
import { createGithubReactionCallback } from './reactions'
|
|
22
|
+
import { createGithubReactionCallback, createGithubRemoveReactionCallback } from './reactions'
|
|
23
23
|
import { createTeamMembershipChecker } from './team-membership'
|
|
24
24
|
import { deregisterGithubWebhooks, registerGithubWebhooks, type WebhookRegistrationResult } from './webhook-register'
|
|
25
25
|
|
|
@@ -125,6 +125,11 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
125
125
|
authType: options.secrets.auth.type,
|
|
126
126
|
fetchImpl,
|
|
127
127
|
})
|
|
128
|
+
const removeReaction = createGithubRemoveReactionCallback({
|
|
129
|
+
token: authToken,
|
|
130
|
+
authType: options.secrets.auth.type,
|
|
131
|
+
fetchImpl,
|
|
132
|
+
})
|
|
128
133
|
const history = createGithubHistoryCallback({
|
|
129
134
|
token: authToken,
|
|
130
135
|
fetchImpl,
|
|
@@ -144,7 +149,10 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
144
149
|
selfId: () => selfId,
|
|
145
150
|
selfLogin: () => selfLogin,
|
|
146
151
|
authType: () => options.secrets.auth.type,
|
|
152
|
+
allowApprove: () => options.configRef().review.approve,
|
|
147
153
|
isBotInTeam,
|
|
154
|
+
authToken,
|
|
155
|
+
fetchImpl,
|
|
148
156
|
logger,
|
|
149
157
|
route: (message) => {
|
|
150
158
|
rememberWorkspace(message.workspace, message.chat)
|
|
@@ -168,6 +176,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
168
176
|
// is fully wired before any webhook can arrive.
|
|
169
177
|
options.router.registerOutbound('github', outbound)
|
|
170
178
|
options.router.registerReaction('github', reaction)
|
|
179
|
+
options.router.registerRemoveReaction('github', removeReaction)
|
|
171
180
|
options.router.registerTyping('github', typing)
|
|
172
181
|
options.router.registerHistory('github', history)
|
|
173
182
|
options.router.registerMembership('github', membership)
|
|
@@ -180,6 +189,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
180
189
|
// and the manager can report the failure cleanly.
|
|
181
190
|
options.router.unregisterOutbound('github', outbound)
|
|
182
191
|
options.router.unregisterReaction('github', reaction)
|
|
192
|
+
options.router.unregisterRemoveReaction('github', removeReaction)
|
|
183
193
|
options.router.unregisterTyping('github', typing)
|
|
184
194
|
options.router.unregisterHistory('github', history)
|
|
185
195
|
options.router.unregisterMembership('github', membership)
|
|
@@ -301,6 +311,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
301
311
|
started = false
|
|
302
312
|
options.router.unregisterOutbound('github', outbound)
|
|
303
313
|
options.router.unregisterReaction('github', reaction)
|
|
314
|
+
options.router.unregisterRemoveReaction('github', removeReaction)
|
|
304
315
|
options.router.unregisterTyping('github', typing)
|
|
305
316
|
options.router.unregisterHistory('github', history)
|
|
306
317
|
options.router.unregisterMembership('github', membership)
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
RemoveReactionCallback,
|
|
3
|
+
ReactionCallback,
|
|
4
|
+
ReactionErrorCode,
|
|
5
|
+
ReactionRef,
|
|
6
|
+
ReactionResult,
|
|
7
|
+
} from '@/channels/types'
|
|
2
8
|
|
|
3
9
|
import type { GithubAuthContext } from './auth'
|
|
4
10
|
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
@@ -21,6 +27,11 @@ export type GithubReactionTarget =
|
|
|
21
27
|
| { kind: 'issue-comment'; owner: string; repo: string; commentId: number }
|
|
22
28
|
| { kind: 'pr-review-comment'; owner: string; repo: string; commentId: number }
|
|
23
29
|
|
|
30
|
+
export type GithubReactionRemovalTarget =
|
|
31
|
+
| { kind: 'issue'; owner: string; repo: string; issueNumber: number; reactionId: number }
|
|
32
|
+
| { kind: 'issue-comment'; owner: string; repo: string; commentId: number; reactionId: number }
|
|
33
|
+
| { kind: 'pr-review-comment'; owner: string; repo: string; commentId: number; reactionId: number }
|
|
34
|
+
|
|
24
35
|
export function encodeGithubReactionRef(target: GithubReactionTarget): ReactionRef {
|
|
25
36
|
return { adapter: 'github', value: JSON.stringify(target) }
|
|
26
37
|
}
|
|
@@ -35,6 +46,7 @@ export function decodeGithubReactionRef(ref: ReactionRef): GithubReactionTarget
|
|
|
35
46
|
}
|
|
36
47
|
if (typeof parsed !== 'object' || parsed === null) return null
|
|
37
48
|
const t = parsed as Record<string, unknown>
|
|
49
|
+
if (t.op !== undefined) return null
|
|
38
50
|
const owner = typeof t.owner === 'string' ? t.owner : null
|
|
39
51
|
const repo = typeof t.repo === 'string' ? t.repo : null
|
|
40
52
|
if (owner === null || repo === null) return null
|
|
@@ -47,6 +59,32 @@ export function decodeGithubReactionRef(ref: ReactionRef): GithubReactionTarget
|
|
|
47
59
|
return null
|
|
48
60
|
}
|
|
49
61
|
|
|
62
|
+
export function encodeGithubRemovalRef(target: GithubReactionRemovalTarget): ReactionRef {
|
|
63
|
+
return { adapter: 'github', value: JSON.stringify({ op: 'remove', ...target }) }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function decodeGithubRemovalRef(ref: ReactionRef): GithubReactionRemovalTarget | null {
|
|
67
|
+
if (ref.adapter !== 'github') return null
|
|
68
|
+
let parsed: unknown
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(ref.value)
|
|
71
|
+
} catch {
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
if (typeof parsed !== 'object' || parsed === null) return null
|
|
75
|
+
const t = parsed as Record<string, unknown>
|
|
76
|
+
const owner = typeof t.owner === 'string' ? t.owner : null
|
|
77
|
+
const repo = typeof t.repo === 'string' ? t.repo : null
|
|
78
|
+
if (t.op !== 'remove' || owner === null || repo === null || typeof t.reactionId !== 'number') return null
|
|
79
|
+
if (t.kind === 'issue' && typeof t.issueNumber === 'number') {
|
|
80
|
+
return { kind: 'issue', owner, repo, issueNumber: t.issueNumber, reactionId: t.reactionId }
|
|
81
|
+
}
|
|
82
|
+
if ((t.kind === 'issue-comment' || t.kind === 'pr-review-comment') && typeof t.commentId === 'number') {
|
|
83
|
+
return { kind: t.kind, owner, repo, commentId: t.commentId, reactionId: t.reactionId }
|
|
84
|
+
}
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
|
|
50
88
|
// GitHub's Reactions API takes a fixed vocabulary of content strings. Map the
|
|
51
89
|
// adapter-generic emoji name onto it; anything outside the set is reported as
|
|
52
90
|
// `unsupported` so the model gets a clear signal rather than a silent 422.
|
|
@@ -82,8 +120,35 @@ export function createGithubReactionCallback(deps: {
|
|
|
82
120
|
const endpoint = reactionEndpoint(target)
|
|
83
121
|
const endpointKind: OutboundEndpointKind =
|
|
84
122
|
target.kind === 'pr-review-comment' ? 'pr-review-comment-reaction' : 'issue-reaction'
|
|
85
|
-
return await postReaction(
|
|
86
|
-
|
|
123
|
+
return await postReaction(
|
|
124
|
+
fetchImpl,
|
|
125
|
+
await deps.token({ repoSlug: `${target.owner}/${target.repo}` }),
|
|
126
|
+
endpoint,
|
|
127
|
+
target,
|
|
128
|
+
{
|
|
129
|
+
content,
|
|
130
|
+
authType: deps.authType,
|
|
131
|
+
endpointKind,
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function createGithubRemoveReactionCallback(deps: {
|
|
138
|
+
token: (context?: GithubAuthContext) => Promise<string>
|
|
139
|
+
authType: GithubAuthType
|
|
140
|
+
fetchImpl?: typeof fetch
|
|
141
|
+
}): RemoveReactionCallback {
|
|
142
|
+
const fetchImpl = deps.fetchImpl ?? fetch
|
|
143
|
+
return async (req): Promise<ReactionResult> => {
|
|
144
|
+
if (req.adapter !== 'github') return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
|
|
145
|
+
const target = decodeGithubRemovalRef(req.reactionRef)
|
|
146
|
+
if (target === null) return { ok: false, error: 'unparseable github reaction removal ref', code: 'unsupported' }
|
|
147
|
+
|
|
148
|
+
const endpoint = removeReactionEndpoint(target)
|
|
149
|
+
const endpointKind: OutboundEndpointKind =
|
|
150
|
+
target.kind === 'pr-review-comment' ? 'pr-review-comment-reaction' : 'issue-reaction'
|
|
151
|
+
return await deleteReaction(fetchImpl, await deps.token({ repoSlug: `${target.owner}/${target.repo}` }), endpoint, {
|
|
87
152
|
authType: deps.authType,
|
|
88
153
|
endpointKind,
|
|
89
154
|
})
|
|
@@ -102,10 +167,23 @@ function reactionEndpoint(target: GithubReactionTarget): string {
|
|
|
102
167
|
}
|
|
103
168
|
}
|
|
104
169
|
|
|
170
|
+
function removeReactionEndpoint(target: GithubReactionRemovalTarget): string {
|
|
171
|
+
const base = `${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}`
|
|
172
|
+
switch (target.kind) {
|
|
173
|
+
case 'issue':
|
|
174
|
+
return `${base}/issues/${target.issueNumber}/reactions/${target.reactionId}`
|
|
175
|
+
case 'issue-comment':
|
|
176
|
+
return `${base}/issues/comments/${target.commentId}/reactions/${target.reactionId}`
|
|
177
|
+
case 'pr-review-comment':
|
|
178
|
+
return `${base}/pulls/comments/${target.commentId}/reactions/${target.reactionId}`
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
105
182
|
async function postReaction(
|
|
106
183
|
fetchImpl: typeof fetch,
|
|
107
184
|
token: string,
|
|
108
185
|
url: string,
|
|
186
|
+
target: GithubReactionTarget,
|
|
109
187
|
options: { content: string; authType: GithubAuthType; endpointKind: OutboundEndpointKind },
|
|
110
188
|
): Promise<ReactionResult> {
|
|
111
189
|
let response: Response
|
|
@@ -121,7 +199,11 @@ async function postReaction(
|
|
|
121
199
|
// 201 = reaction created, 200 = the actor already left this same reaction.
|
|
122
200
|
// Both are success: an :eyes: that's already there is the desired end state,
|
|
123
201
|
// so a duplicate webhook delivery (or a retried engage) must not surface an error.
|
|
124
|
-
if (response.status === 200 || response.status === 201)
|
|
202
|
+
if (response.status === 200 || response.status === 201) {
|
|
203
|
+
const reactionId = await readReactionId(response)
|
|
204
|
+
if (reactionId === null) return { ok: true }
|
|
205
|
+
return { ok: true, reactionRef: encodeGithubRemovalRef(removalTargetFor(target, reactionId)) }
|
|
206
|
+
}
|
|
125
207
|
const text = await response.text().catch(() => '')
|
|
126
208
|
const baseError = `GitHub API ${response.status}${text !== '' ? `: ${text}` : ''}`
|
|
127
209
|
if (isOutboundPermissionDenial(response.status, text)) {
|
|
@@ -134,6 +216,58 @@ async function postReaction(
|
|
|
134
216
|
return { ok: false, error: baseError, code: classifyStatus(response.status) }
|
|
135
217
|
}
|
|
136
218
|
|
|
219
|
+
async function deleteReaction(
|
|
220
|
+
fetchImpl: typeof fetch,
|
|
221
|
+
token: string,
|
|
222
|
+
url: string,
|
|
223
|
+
options: { authType: GithubAuthType; endpointKind: OutboundEndpointKind },
|
|
224
|
+
): Promise<ReactionResult> {
|
|
225
|
+
let response: Response
|
|
226
|
+
try {
|
|
227
|
+
response = await fetchImpl(url, {
|
|
228
|
+
method: 'DELETE',
|
|
229
|
+
headers: githubJsonHeaders(token),
|
|
230
|
+
})
|
|
231
|
+
} catch (err) {
|
|
232
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err), code: 'transient' }
|
|
233
|
+
}
|
|
234
|
+
if (response.status === 204) return { ok: true }
|
|
235
|
+
const text = await response.text().catch(() => '')
|
|
236
|
+
const baseError = `GitHub API ${response.status}${text !== '' ? `: ${text}` : ''}`
|
|
237
|
+
if (isOutboundPermissionDenial(response.status, text)) {
|
|
238
|
+
return {
|
|
239
|
+
ok: false,
|
|
240
|
+
error: `${baseError}${buildOutboundPermissionGuidance({ authType: options.authType, endpointKind: options.endpointKind })}`,
|
|
241
|
+
code: 'permission-denied',
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return { ok: false, error: baseError, code: classifyStatus(response.status) }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function removalTargetFor(target: GithubReactionTarget, reactionId: number): GithubReactionRemovalTarget {
|
|
248
|
+
switch (target.kind) {
|
|
249
|
+
case 'issue':
|
|
250
|
+
return { kind: 'issue', owner: target.owner, repo: target.repo, issueNumber: target.issueNumber, reactionId }
|
|
251
|
+
case 'issue-comment':
|
|
252
|
+
return { kind: 'issue-comment', owner: target.owner, repo: target.repo, commentId: target.commentId, reactionId }
|
|
253
|
+
case 'pr-review-comment':
|
|
254
|
+
return {
|
|
255
|
+
kind: 'pr-review-comment',
|
|
256
|
+
owner: target.owner,
|
|
257
|
+
repo: target.repo,
|
|
258
|
+
commentId: target.commentId,
|
|
259
|
+
reactionId,
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function readReactionId(response: Response): Promise<number | null> {
|
|
265
|
+
const body = await response.json().catch(() => null)
|
|
266
|
+
if (typeof body !== 'object' || body === null) return null
|
|
267
|
+
const id = (body as Record<string, unknown>).id
|
|
268
|
+
return typeof id === 'number' ? id : null
|
|
269
|
+
}
|
|
270
|
+
|
|
137
271
|
function classifyStatus(status: number): ReactionErrorCode {
|
|
138
272
|
if (status === 403) return 'permission-denied'
|
|
139
273
|
if (status === 404) return 'not-found'
|
|
@@ -182,7 +182,7 @@ function describeSlackMedia(event: SlackInboundMessageEvent): InboundAttachment[
|
|
|
182
182
|
return (event.files ?? []).map((file, index) => describeSlackFile(file, index + 1))
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
function describeSlackFile(file: SlackFile, id: number): InboundAttachment {
|
|
185
|
+
export function describeSlackFile(file: SlackFile, id: number): InboundAttachment {
|
|
186
186
|
return {
|
|
187
187
|
id,
|
|
188
188
|
kind: 'file',
|
|
@@ -192,7 +192,7 @@ function describeSlackFile(file: SlackFile, id: number): InboundAttachment {
|
|
|
192
192
|
}
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
function renderPlaceholder(attachment: InboundAttachment): string {
|
|
195
|
+
export function renderPlaceholder(attachment: InboundAttachment): string {
|
|
196
196
|
const parts: string[] = [`Slack attachment #${attachment.id}: ${attachment.kind}`]
|
|
197
197
|
if (attachment.mimetype !== undefined) parts.push(attachment.mimetype)
|
|
198
198
|
if (attachment.filename !== undefined) parts.push(`name=${attachment.filename}`)
|