typeclaw 0.20.0 → 0.21.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/restart/index.ts +101 -0
- package/src/agent/tools/restart.ts +23 -52
- package/src/channels/adapters/discord-bot-classify.ts +8 -2
- package/src/channels/adapters/discord-bot.ts +27 -2
- package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
- package/src/channels/adapters/github/inbound.ts +69 -0
- package/src/channels/adapters/github/index.ts +11 -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 +25 -2
- package/src/channels/engagement.ts +71 -31
- package/src/channels/router.ts +112 -10
- package/src/channels/types.ts +16 -1
- package/src/cli/builtins.ts +1 -0
- package/src/cli/dreams.ts +147 -0
- package/src/cli/index.ts +1 -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/server/index.ts +49 -0
- package/src/shared/protocol.ts +2 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +3 -9
- package/src/tui/index.ts +70 -18
package/package.json
CHANGED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { writeRestartHandoff } from '@/agent/restart-handoff'
|
|
4
|
+
import { send, sendHttp } from '@/hostd/client'
|
|
5
|
+
import { containerSocketPath } from '@/hostd/paths'
|
|
6
|
+
import type { Stream } from '@/stream'
|
|
7
|
+
|
|
8
|
+
const ACK_TIMEOUT_MS = 5_000
|
|
9
|
+
|
|
10
|
+
export type ContainerRestartingBroadcast = {
|
|
11
|
+
kind: 'container-restarting'
|
|
12
|
+
restartedAt: string
|
|
13
|
+
originatingSessionId: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type RequestContainerRestartOptions = {
|
|
17
|
+
containerName: string
|
|
18
|
+
build?: boolean
|
|
19
|
+
socketPath?: string
|
|
20
|
+
hostdUrl?: string
|
|
21
|
+
hostdToken?: string
|
|
22
|
+
ackTimeoutMs?: number
|
|
23
|
+
// When present together with originatingSessionId, the post-ACK
|
|
24
|
+
// container-restarting broadcast is published here so every live session's
|
|
25
|
+
// subscribeRestartNotice fans out the restart notice (originator gets
|
|
26
|
+
// typeclaw.restart-self, siblings get typeclaw.restart). Both the tool and
|
|
27
|
+
// the server /restart path route through this so the broadcast->handoff
|
|
28
|
+
// ordering lives in one place.
|
|
29
|
+
stream?: Stream
|
|
30
|
+
agentDir?: string
|
|
31
|
+
originatingSessionId?: string
|
|
32
|
+
originatingSessionFile?: string
|
|
33
|
+
restartedAt?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type RequestContainerRestartResult =
|
|
37
|
+
| { ok: true; containerName: string; restartedAt: string }
|
|
38
|
+
| { ok: false; containerName: string; reason: string }
|
|
39
|
+
|
|
40
|
+
export async function requestContainerRestart({
|
|
41
|
+
containerName,
|
|
42
|
+
build,
|
|
43
|
+
socketPath,
|
|
44
|
+
hostdUrl,
|
|
45
|
+
hostdToken,
|
|
46
|
+
ackTimeoutMs,
|
|
47
|
+
stream,
|
|
48
|
+
agentDir,
|
|
49
|
+
originatingSessionId,
|
|
50
|
+
originatingSessionFile,
|
|
51
|
+
restartedAt,
|
|
52
|
+
}: RequestContainerRestartOptions): Promise<RequestContainerRestartResult> {
|
|
53
|
+
const request = { kind: 'restart' as const, containerName, build: build === true }
|
|
54
|
+
const httpUrl = hostdUrl ?? process.env.TYPECLAW_HOSTD_URL
|
|
55
|
+
const httpToken = hostdToken ?? process.env.TYPECLAW_HOSTD_TOKEN
|
|
56
|
+
const ackBudget = ackTimeoutMs ?? ACK_TIMEOUT_MS
|
|
57
|
+
const reply =
|
|
58
|
+
httpUrl && httpToken
|
|
59
|
+
? await sendHttp(request, { timeoutMs: ackBudget, url: httpUrl, token: httpToken })
|
|
60
|
+
: await send(request, { timeoutMs: ackBudget, socket: socketPath ?? containerSocketPath() })
|
|
61
|
+
|
|
62
|
+
if (!reply.ok) return { ok: false, containerName, reason: reply.reason }
|
|
63
|
+
|
|
64
|
+
const restartTimestamp = restartedAt ?? new Date().toISOString()
|
|
65
|
+
|
|
66
|
+
// Fan out the restart notice to every live session BEFORE writing the handoff.
|
|
67
|
+
// The originating session's subscribeRestartNotice appends the
|
|
68
|
+
// typeclaw.restart-self entry synchronously (broker delivery + the JSONL
|
|
69
|
+
// append are both synchronous), so the handoff below points at a JSONL that
|
|
70
|
+
// already carries the "I'm back" instruction the rebooted container hydrates.
|
|
71
|
+
// Only after an accepted ACK, never on a failed/timed-out restart.
|
|
72
|
+
if (stream !== undefined && originatingSessionId !== undefined) {
|
|
73
|
+
const broadcast: ContainerRestartingBroadcast = {
|
|
74
|
+
kind: 'container-restarting',
|
|
75
|
+
restartedAt: restartTimestamp,
|
|
76
|
+
originatingSessionId,
|
|
77
|
+
}
|
|
78
|
+
stream.publish({ target: { kind: 'broadcast' }, payload: broadcast })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Post-ACK: hostd has committed the restart, so a handoff-write failure must
|
|
82
|
+
// never demote it to a failure — that would render a false error in the TUI
|
|
83
|
+
// and swallow the accepted response. The handoff is a best-effort resume hint
|
|
84
|
+
// only; a missing one just cold-starts the rebooted container without the
|
|
85
|
+
// "I'm back" greeting. writeRestartHandoff swallows its own errors today, but
|
|
86
|
+
// guard here too so this contract survives the writer being changed later.
|
|
87
|
+
if (agentDir !== undefined && originatingSessionId !== undefined && originatingSessionFile !== undefined) {
|
|
88
|
+
try {
|
|
89
|
+
await writeRestartHandoff(agentDir, {
|
|
90
|
+
schemaVersion: 1,
|
|
91
|
+
restartedAt: restartTimestamp,
|
|
92
|
+
originatingSessionId,
|
|
93
|
+
originatingSessionFile: basename(originatingSessionFile),
|
|
94
|
+
})
|
|
95
|
+
} catch {
|
|
96
|
+
// intentional swallow — see the post-ACK rationale above
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { ok: true, containerName, restartedAt: restartTimestamp }
|
|
101
|
+
}
|
|
@@ -1,14 +1,9 @@
|
|
|
1
|
-
import { basename } from 'node:path'
|
|
2
|
-
|
|
3
1
|
import { Type } from '@mariozechner/pi-ai'
|
|
4
2
|
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
5
3
|
|
|
6
|
-
import {
|
|
7
|
-
import { send, sendHttp } from '@/hostd/client'
|
|
8
|
-
import { containerSocketPath } from '@/hostd/paths'
|
|
4
|
+
import { requestContainerRestart } from '@/agent/restart'
|
|
9
5
|
import type { Stream } from '@/stream'
|
|
10
6
|
|
|
11
|
-
const ACK_TIMEOUT_MS = 5_000
|
|
12
7
|
const EXIT_DELAY_MS = 500
|
|
13
8
|
|
|
14
9
|
export type CreateRestartToolOptions = {
|
|
@@ -61,11 +56,7 @@ export type CreateRestartToolOptions = {
|
|
|
61
56
|
|
|
62
57
|
export type RestartToolDetails = { ok: boolean; containerName: string; reason?: string }
|
|
63
58
|
|
|
64
|
-
export type ContainerRestartingBroadcast
|
|
65
|
-
kind: 'container-restarting'
|
|
66
|
-
restartedAt: string
|
|
67
|
-
originatingSessionId: string
|
|
68
|
-
}
|
|
59
|
+
export type { ContainerRestartingBroadcast } from '@/agent/restart'
|
|
69
60
|
|
|
70
61
|
export function createRestartTool({
|
|
71
62
|
containerName,
|
|
@@ -80,9 +71,6 @@ export function createRestartTool({
|
|
|
80
71
|
originatingSessionFile,
|
|
81
72
|
}: CreateRestartToolOptions) {
|
|
82
73
|
const doExit = exit ?? ((code: number) => process.exit(code))
|
|
83
|
-
const httpUrl = hostdUrl ?? process.env.TYPECLAW_HOSTD_URL
|
|
84
|
-
const ackBudget = ackTimeoutMs ?? ACK_TIMEOUT_MS
|
|
85
|
-
const httpToken = hostdToken ?? process.env.TYPECLAW_HOSTD_TOKEN
|
|
86
74
|
|
|
87
75
|
return defineTool({
|
|
88
76
|
name: 'restart',
|
|
@@ -109,49 +97,32 @@ export function createRestartTool({
|
|
|
109
97
|
}),
|
|
110
98
|
async execute(_toolCallId, params) {
|
|
111
99
|
const build = params.build === true
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
100
|
+
// requestContainerRestart owns the post-ACK broadcast->handoff ordering:
|
|
101
|
+
// on a successful ACK it publishes the container-restarting notice (which
|
|
102
|
+
// every live session's subscribeRestartNotice turns into a transcript
|
|
103
|
+
// entry) and then writes the handoff. Handoff fields are gated to TUI
|
|
104
|
+
// origins by the caller passing originatingSessionFile only for those —
|
|
105
|
+
// see issue #291's scoping concerns.
|
|
106
|
+
const result = await requestContainerRestart({
|
|
107
|
+
containerName,
|
|
108
|
+
build,
|
|
109
|
+
originatingSessionId,
|
|
110
|
+
...(socketPath !== undefined ? { socketPath } : {}),
|
|
111
|
+
...(hostdUrl !== undefined ? { hostdUrl } : {}),
|
|
112
|
+
...(hostdToken !== undefined ? { hostdToken } : {}),
|
|
113
|
+
...(ackTimeoutMs !== undefined ? { ackTimeoutMs } : {}),
|
|
114
|
+
...(stream !== undefined ? { stream } : {}),
|
|
115
|
+
...(agentDir !== undefined ? { agentDir } : {}),
|
|
116
|
+
...(originatingSessionFile !== undefined ? { originatingSessionFile } : {}),
|
|
117
|
+
})
|
|
118
|
+
if (!result.ok) {
|
|
119
|
+
const details: RestartToolDetails = { ok: false, containerName, reason: result.reason }
|
|
119
120
|
return {
|
|
120
|
-
content: [{ type: 'text' as const, text: `restart denied: ${
|
|
121
|
+
content: [{ type: 'text' as const, text: `restart denied: ${result.reason}` }],
|
|
121
122
|
details,
|
|
122
123
|
}
|
|
123
124
|
}
|
|
124
125
|
|
|
125
|
-
// Hostd ACK == restart is committed. Fan out the notice to every live
|
|
126
|
-
// session BEFORE arming the exit timer. Stream broker delivery is
|
|
127
|
-
// synchronous (broker.ts deliver()) and SessionManager.appendCustomMessageEntry
|
|
128
|
-
// does a synchronous JSONL write, so the fan-out completes inside this
|
|
129
|
-
// tick — well before the EXIT_DELAY_MS timer fires.
|
|
130
|
-
const restartedAt = new Date().toISOString()
|
|
131
|
-
const broadcast: ContainerRestartingBroadcast = {
|
|
132
|
-
kind: 'container-restarting',
|
|
133
|
-
restartedAt,
|
|
134
|
-
originatingSessionId,
|
|
135
|
-
}
|
|
136
|
-
stream?.publish({ target: { kind: 'broadcast' }, payload: broadcast })
|
|
137
|
-
|
|
138
|
-
// Write the cross-restart handoff AFTER the broadcast has run so the
|
|
139
|
-
// originating session's JSONL already contains the `typeclaw.restart-self`
|
|
140
|
-
// custom message entry that the next container will hydrate on
|
|
141
|
-
// `SessionManager.open`. Without that ordering, the new container could
|
|
142
|
-
// theoretically open the JSONL before the entry was flushed and miss
|
|
143
|
-
// the model-instruction the entry carries. Gated on agentDir +
|
|
144
|
-
// originatingSessionFile so non-TUI origins (channel/cron/subagent)
|
|
145
|
-
// skip the file write — see issue #291's scoping concerns.
|
|
146
|
-
if (agentDir !== undefined && originatingSessionFile !== undefined) {
|
|
147
|
-
await writeRestartHandoff(agentDir, {
|
|
148
|
-
schemaVersion: 1,
|
|
149
|
-
restartedAt,
|
|
150
|
-
originatingSessionId,
|
|
151
|
-
originatingSessionFile: basename(originatingSessionFile),
|
|
152
|
-
})
|
|
153
|
-
}
|
|
154
|
-
|
|
155
126
|
// Schedule the exit on the next tick so the tool result is delivered to
|
|
156
127
|
// the model before the process dies. The host daemon polls for the
|
|
157
128
|
// container's removal before re-running `start`, so a small delay here
|
|
@@ -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,
|
|
@@ -487,6 +495,9 @@ type DiscordRawHistoryMessage = {
|
|
|
487
495
|
content: string
|
|
488
496
|
timestamp: string
|
|
489
497
|
message_reference?: { message_id?: string }
|
|
498
|
+
attachments?: DiscordFile[]
|
|
499
|
+
embeds?: DiscordGatewayEmbed[]
|
|
500
|
+
sticker_items?: DiscordGatewayStickerItem[]
|
|
490
501
|
}
|
|
491
502
|
|
|
492
503
|
// Discord treats threads as separate channels with their own snowflake ids,
|
|
@@ -551,14 +562,28 @@ export function createDiscordHistoryCallback(deps: {
|
|
|
551
562
|
function mapDiscordMessage(msg: DiscordRawHistoryMessage, botUserId: string | null): ChannelHistoryMessage {
|
|
552
563
|
const isBot = msg.author.bot === true || (botUserId !== null && msg.author.id === botUserId)
|
|
553
564
|
const ts = Date.parse(msg.timestamp)
|
|
565
|
+
// The REST history fetch bypasses the inbound classifier, so attachments,
|
|
566
|
+
// embeds, and stickers on already-posted messages (e.g. an image on a thread
|
|
567
|
+
// root the agent is later @-mentioned under) must be mapped here too —
|
|
568
|
+
// otherwise they are silently dropped and look_at_channel_attachment can
|
|
569
|
+
// never resolve them. Mirror the classifier's splitInbound: bake placeholders
|
|
570
|
+
// into text and carry the structured attachments so the router can resolve ids.
|
|
571
|
+
const attachments = describeDiscordMedia(msg)
|
|
572
|
+
const text =
|
|
573
|
+
attachments.length === 0
|
|
574
|
+
? msg.content
|
|
575
|
+
: msg.content === ''
|
|
576
|
+
? attachments.map(renderPlaceholder).join('\n')
|
|
577
|
+
: `${msg.content}\n${attachments.map(renderPlaceholder).join('\n')}`
|
|
554
578
|
return {
|
|
555
579
|
externalMessageId: msg.id,
|
|
556
580
|
authorId: msg.author.id,
|
|
557
581
|
authorName: msg.author.global_name ?? msg.author.username ?? msg.author.id,
|
|
558
|
-
text
|
|
582
|
+
text,
|
|
559
583
|
ts: Number.isFinite(ts) ? ts : 0,
|
|
560
584
|
isBot,
|
|
561
585
|
replyToBotMessageId: msg.message_reference?.message_id ?? null,
|
|
586
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
562
587
|
}
|
|
563
588
|
}
|
|
564
589
|
|
|
@@ -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'
|
|
@@ -23,6 +25,13 @@ export type GithubWebhookHandlerOptions = {
|
|
|
23
25
|
// omitted, team-reviewer requests are silently dropped (the v1 fallback
|
|
24
26
|
// behavior). The adapter wires this in production; tests inject a fake.
|
|
25
27
|
isBotInTeam?: (input: { org: string; slug: string; login: string }) => Promise<boolean>
|
|
28
|
+
// App-auth only: mints a repo-scoped token used to drop the decoy reviewer
|
|
29
|
+
// once the bot's own review lands. Omitted under PAT auth (no decoy exists).
|
|
30
|
+
authToken?: (context?: GithubAuthContext) => Promise<string>
|
|
31
|
+
// Schedules the decoy-drop off the webhook ACK path so the 200 stays fast.
|
|
32
|
+
// Defaults to fire-and-forget; tests inject a recorder to await the task.
|
|
33
|
+
scheduleBackgroundTask?: (task: () => Promise<void>) => void
|
|
34
|
+
fetchImpl?: typeof fetch
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions): (req: Request) => Promise<Response> {
|
|
@@ -51,6 +60,7 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
|
|
|
51
60
|
const selfLogin = options.selfLogin()
|
|
52
61
|
const author = readAuthor(event, payload)
|
|
53
62
|
if (author !== null && isSelfAuthor(author, selfId, selfLogin)) {
|
|
63
|
+
maybeScheduleDecoyReviewerDrop({ event, action, payload, selfLogin, options })
|
|
54
64
|
options.logger.info(
|
|
55
65
|
`[github] dropped self-authored ${event}${action !== null ? `.${action}` : ''} from @${author.login}`,
|
|
56
66
|
)
|
|
@@ -70,6 +80,65 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
|
|
|
70
80
|
}
|
|
71
81
|
}
|
|
72
82
|
|
|
83
|
+
// GitHub auto-records the App as a reviewer the moment its review posts, but
|
|
84
|
+
// leaves the decoy user pinned as a perpetual "review requested". When the bot
|
|
85
|
+
// drops its own review (the self-authored event we're about to discard), fire a
|
|
86
|
+
// background DELETE to remove the decoy. The DELETE is authenticated as the App,
|
|
87
|
+
// so the resulting review_request_removed webhook has the bot actor as sender
|
|
88
|
+
// and is dropped by classifyReviewRequest's self-loop guard — no fresh session.
|
|
89
|
+
function maybeScheduleDecoyReviewerDrop(input: {
|
|
90
|
+
event: string
|
|
91
|
+
action: string | null
|
|
92
|
+
payload: Record<string, unknown>
|
|
93
|
+
selfLogin: string | null
|
|
94
|
+
options: GithubWebhookHandlerOptions
|
|
95
|
+
}): void {
|
|
96
|
+
const { event, action, payload, selfLogin, options } = input
|
|
97
|
+
if (event !== 'pull_request_review' || action !== 'submitted') return
|
|
98
|
+
if (selfLogin === null) return
|
|
99
|
+
const authToken = options.authToken
|
|
100
|
+
if (authToken === undefined) return
|
|
101
|
+
if ((options.authType?.() ?? 'pat') !== 'app') return
|
|
102
|
+
const decoyLogin = resolveDecoyReviewerLogin(selfLogin, 'app')
|
|
103
|
+
if (decoyLogin === null) return
|
|
104
|
+
|
|
105
|
+
const repository = readRepository(payload)
|
|
106
|
+
const pr = readRecord(payload.pull_request)
|
|
107
|
+
const pullNumber = readNumber(pr, 'number')
|
|
108
|
+
if (repository === null || pullNumber === null) return
|
|
109
|
+
|
|
110
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
111
|
+
const schedule = options.scheduleBackgroundTask ?? defaultScheduleBackgroundTask
|
|
112
|
+
const target = `${repository.owner}/${repository.name}#${pullNumber}`
|
|
113
|
+
schedule(async () => {
|
|
114
|
+
// authToken can throw (installation lookup / token mint), and a thrown
|
|
115
|
+
// failure must still warn — the default scheduler swallows rejections, so
|
|
116
|
+
// catching here is the only place the failure is observable.
|
|
117
|
+
try {
|
|
118
|
+
const token = await authToken({ repoSlug: `${repository.owner}/${repository.name}` })
|
|
119
|
+
const result = await removeRequestedReviewer({
|
|
120
|
+
fetchImpl,
|
|
121
|
+
token,
|
|
122
|
+
owner: repository.owner,
|
|
123
|
+
repo: repository.name,
|
|
124
|
+
pullNumber,
|
|
125
|
+
reviewerLogin: decoyLogin,
|
|
126
|
+
})
|
|
127
|
+
if (result.kind === 'failed') {
|
|
128
|
+
options.logger.warn(`[github] failed to drop decoy reviewer @${decoyLogin} from ${target}: ${result.reason}`)
|
|
129
|
+
}
|
|
130
|
+
} catch (err) {
|
|
131
|
+
options.logger.warn(
|
|
132
|
+
`[github] failed to drop decoy reviewer @${decoyLogin} from ${target}: ${err instanceof Error ? err.message : String(err)}`,
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function defaultScheduleBackgroundTask(task: () => Promise<void>): void {
|
|
139
|
+
void task().catch(() => {})
|
|
140
|
+
}
|
|
141
|
+
|
|
73
142
|
export async function verifySignature(body: string, secret: string, sigHeader: string): Promise<boolean> {
|
|
74
143
|
const expected = `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`
|
|
75
144
|
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,
|
|
@@ -145,6 +150,8 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
145
150
|
selfLogin: () => selfLogin,
|
|
146
151
|
authType: () => options.secrets.auth.type,
|
|
147
152
|
isBotInTeam,
|
|
153
|
+
authToken,
|
|
154
|
+
fetchImpl,
|
|
148
155
|
logger,
|
|
149
156
|
route: (message) => {
|
|
150
157
|
rememberWorkspace(message.workspace, message.chat)
|
|
@@ -168,6 +175,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
168
175
|
// is fully wired before any webhook can arrive.
|
|
169
176
|
options.router.registerOutbound('github', outbound)
|
|
170
177
|
options.router.registerReaction('github', reaction)
|
|
178
|
+
options.router.registerRemoveReaction('github', removeReaction)
|
|
171
179
|
options.router.registerTyping('github', typing)
|
|
172
180
|
options.router.registerHistory('github', history)
|
|
173
181
|
options.router.registerMembership('github', membership)
|
|
@@ -180,6 +188,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
180
188
|
// and the manager can report the failure cleanly.
|
|
181
189
|
options.router.unregisterOutbound('github', outbound)
|
|
182
190
|
options.router.unregisterReaction('github', reaction)
|
|
191
|
+
options.router.unregisterRemoveReaction('github', removeReaction)
|
|
183
192
|
options.router.unregisterTyping('github', typing)
|
|
184
193
|
options.router.unregisterHistory('github', history)
|
|
185
194
|
options.router.unregisterMembership('github', membership)
|
|
@@ -301,6 +310,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
301
310
|
started = false
|
|
302
311
|
options.router.unregisterOutbound('github', outbound)
|
|
303
312
|
options.router.unregisterReaction('github', reaction)
|
|
313
|
+
options.router.unregisterRemoveReaction('github', removeReaction)
|
|
304
314
|
options.router.unregisterTyping('github', typing)
|
|
305
315
|
options.router.unregisterHistory('github', history)
|
|
306
316
|
options.router.unregisterMembership('github', membership)
|