typeclaw 0.17.0 → 0.19.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/auth.schema.json +0 -5
- package/package.json +2 -2
- package/secrets.schema.json +0 -5
- package/src/agent/index.ts +2 -1
- package/src/agent/model-overrides.ts +77 -0
- package/src/agent/plugin-tools.ts +53 -4
- package/src/agent/tools/grant-role.ts +102 -8
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +372 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +42 -0
- package/src/bundled-plugins/github-cli-auth/token-class.ts +11 -0
- package/src/bundled-plugins/reviewer/skills/code-review.ts +18 -1
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +9 -2
- package/src/channels/adapters/discord-bot-classify.ts +23 -0
- package/src/channels/adapters/discord-bot.ts +22 -4
- package/src/channels/adapters/github/auth-app.ts +49 -26
- package/src/channels/adapters/github/auth-pat.ts +3 -3
- package/src/channels/adapters/github/auth.ts +19 -5
- package/src/channels/adapters/github/channel-resolver.ts +3 -2
- package/src/channels/adapters/github/history.ts +3 -2
- package/src/channels/adapters/github/inbound.ts +30 -55
- package/src/channels/adapters/github/index.ts +147 -43
- package/src/channels/adapters/github/membership.ts +7 -2
- package/src/channels/adapters/github/outbound.ts +6 -2
- package/src/channels/adapters/github/team-membership.ts +4 -2
- package/src/channels/adapters/github/webhook-register.ts +19 -16
- package/src/channels/adapters/slack-bot-slash-commands.ts +78 -1
- package/src/channels/adapters/slack-bot.ts +119 -18
- package/src/channels/commands.ts +10 -0
- package/src/channels/engagement.ts +34 -3
- package/src/channels/github-token-bridge.ts +42 -0
- package/src/channels/index.ts +6 -0
- package/src/channels/manager.ts +6 -0
- package/src/channels/membership.ts +9 -0
- package/src/channels/router.ts +155 -37
- package/src/cli/channel.ts +0 -12
- package/src/cli/init.ts +0 -9
- package/src/cli/ui.ts +6 -0
- package/src/commands/index.ts +54 -4
- package/src/init/dockerfile.ts +60 -0
- package/src/init/github-webhook-install.ts +1 -2
- package/src/init/index.ts +4 -10
- package/src/init/validate-api-key.ts +15 -1
- package/src/plugin/context.ts +8 -0
- package/src/plugin/manager.ts +3 -0
- package/src/plugin/types.ts +6 -0
- package/src/run/bundled-plugins.ts +9 -0
- package/src/run/index.ts +6 -0
- package/src/secrets/schema.ts +0 -1
- package/src/server/command-runner.ts +14 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +70 -43
|
@@ -2,33 +2,43 @@ import { createPrivateKey } from 'node:crypto'
|
|
|
2
2
|
|
|
3
3
|
import { resolveSecret, type Secret } from '@/secrets/resolve'
|
|
4
4
|
|
|
5
|
-
import type { GithubAuthStrategy, GithubInstallationGrants, GithubSelfUser } from './auth'
|
|
5
|
+
import type { GithubAuthContext, GithubAuthStrategy, GithubInstallationGrants, GithubSelfUser } from './auth'
|
|
6
6
|
import { GITHUB_API_BASE, githubJsonHeaders, githubPublicHeaders } from './auth-pat'
|
|
7
7
|
|
|
8
|
+
type TokenCacheEntry = { value: string; expiresAt: number }
|
|
9
|
+
|
|
8
10
|
export class AppAuthStrategy implements GithubAuthStrategy {
|
|
9
11
|
private readonly appId: number
|
|
10
12
|
private readonly privateKeyPem: string
|
|
11
|
-
private readonly installationId: number | null
|
|
12
13
|
private readonly fetchImpl: typeof fetch
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
// Keyed by installation id: a single App may span multiple owners, each a
|
|
15
|
+
// separate installation with its own short-lived token.
|
|
16
|
+
private readonly tokenCache = new Map<number, TokenCacheEntry>()
|
|
17
|
+
private readonly repoInstallationCache = new Map<string, number>()
|
|
18
|
+
private soleInstallationId: number | null = null
|
|
15
19
|
private _selfUser: GithubSelfUser | null = null
|
|
16
20
|
|
|
17
|
-
constructor(options: { appId: number; privateKey: Secret;
|
|
21
|
+
constructor(options: { appId: number; privateKey: Secret; fetchImpl?: typeof fetch }) {
|
|
18
22
|
const privateKeyPem = resolveSecret(options.privateKey, undefined, process.env)
|
|
19
23
|
if (privateKeyPem === undefined || privateKeyPem.trim() === '') throw new Error('GitHub App private key is missing')
|
|
20
24
|
this.appId = options.appId
|
|
21
25
|
this.privateKeyPem = privateKeyPem
|
|
22
|
-
this.installationId = options.installationId ?? null
|
|
23
26
|
this.fetchImpl = options.fetchImpl ?? fetch
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
async token(): Promise<string> {
|
|
27
|
-
if (this.cachedToken && Date.now() < this.cachedToken.expiresAt - 5 * 60 * 1000) {
|
|
28
|
-
return this.cachedToken.value
|
|
29
|
-
}
|
|
29
|
+
async token(context?: GithubAuthContext): Promise<string> {
|
|
30
30
|
const jwt = await this.mintJwt()
|
|
31
|
-
const installId = await this.resolveInstallationId(jwt)
|
|
31
|
+
const installId = await this.resolveInstallationId(jwt, context)
|
|
32
|
+
return this.installationToken(jwt, installId)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async authHeaders(context?: GithubAuthContext): Promise<HeadersInit> {
|
|
36
|
+
return githubJsonHeaders(await this.token(context))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private async installationToken(jwt: string, installId: number): Promise<string> {
|
|
40
|
+
const cached = this.tokenCache.get(installId)
|
|
41
|
+
if (cached && Date.now() < cached.expiresAt - 5 * 60 * 1000) return cached.value
|
|
32
42
|
const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations/${installId}/access_tokens`, {
|
|
33
43
|
method: 'POST',
|
|
34
44
|
headers: githubJsonHeaders(jwt),
|
|
@@ -37,14 +47,10 @@ export class AppAuthStrategy implements GithubAuthStrategy {
|
|
|
37
47
|
const raw = (await response.json()) as { token?: unknown; expires_at?: unknown }
|
|
38
48
|
if (typeof raw.token !== 'string') throw new Error('GitHub App token response missing token')
|
|
39
49
|
const expiresAt = typeof raw.expires_at === 'string' ? Date.parse(raw.expires_at) : Date.now() + 60 * 60 * 1000
|
|
40
|
-
this.
|
|
50
|
+
this.tokenCache.set(installId, { value: raw.token, expiresAt })
|
|
41
51
|
return raw.token
|
|
42
52
|
}
|
|
43
53
|
|
|
44
|
-
async authHeaders(): Promise<HeadersInit> {
|
|
45
|
-
return githubJsonHeaders(await this.token())
|
|
46
|
-
}
|
|
47
|
-
|
|
48
54
|
async getSelf(): Promise<GithubSelfUser> {
|
|
49
55
|
if (this._selfUser) return this._selfUser
|
|
50
56
|
const jwt = await this.mintJwt()
|
|
@@ -69,9 +75,9 @@ export class AppAuthStrategy implements GithubAuthStrategy {
|
|
|
69
75
|
return this._selfUser
|
|
70
76
|
}
|
|
71
77
|
|
|
72
|
-
async getInstallationGrants(): Promise<GithubInstallationGrants> {
|
|
78
|
+
async getInstallationGrants(context?: GithubAuthContext): Promise<GithubInstallationGrants> {
|
|
73
79
|
const jwt = await this.mintJwt()
|
|
74
|
-
const installId = await this.resolveInstallationId(jwt)
|
|
80
|
+
const installId = await this.resolveInstallationId(jwt, context)
|
|
75
81
|
const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations/${installId}`, {
|
|
76
82
|
headers: githubJsonHeaders(jwt),
|
|
77
83
|
})
|
|
@@ -88,7 +94,8 @@ export class AppAuthStrategy implements GithubAuthStrategy {
|
|
|
88
94
|
}
|
|
89
95
|
|
|
90
96
|
async dispose(): Promise<void> {
|
|
91
|
-
this.
|
|
97
|
+
this.tokenCache.clear()
|
|
98
|
+
this.repoInstallationCache.clear()
|
|
92
99
|
}
|
|
93
100
|
|
|
94
101
|
private async mintJwt(): Promise<string> {
|
|
@@ -107,25 +114,41 @@ export class AppAuthStrategy implements GithubAuthStrategy {
|
|
|
107
114
|
return `${signingInput}.${base64url(Buffer.from(signature))}`
|
|
108
115
|
}
|
|
109
116
|
|
|
110
|
-
private async resolveInstallationId(jwt: string): Promise<number> {
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
117
|
+
private async resolveInstallationId(jwt: string, context?: GithubAuthContext): Promise<number> {
|
|
118
|
+
if (context?.repoSlug !== undefined && context.repoSlug !== '') {
|
|
119
|
+
return this.resolveInstallationByEndpoint(jwt, `repos/${context.repoSlug}/installation`, context.repoSlug)
|
|
120
|
+
}
|
|
121
|
+
if (context?.owner !== undefined && context.owner !== '') {
|
|
122
|
+
return this.resolveInstallationByEndpoint(jwt, `orgs/${context.owner}/installation`, context.owner)
|
|
115
123
|
}
|
|
124
|
+
if (this.soleInstallationId !== null) return this.soleInstallationId
|
|
116
125
|
const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations`, { headers: githubJsonHeaders(jwt) })
|
|
117
126
|
if (!response.ok) throw new Error(`GitHub App installations fetch failed: ${response.status}`)
|
|
118
127
|
const list = (await response.json()) as Array<{ id?: unknown }>
|
|
119
128
|
if (list.length === 0) throw new Error('GitHub App has no installations')
|
|
120
129
|
if (list.length > 1) {
|
|
121
130
|
const ids = list.map((installation) => installation.id).join(', ')
|
|
122
|
-
throw new Error(`GitHub App has multiple installations (${ids});
|
|
131
|
+
throw new Error(`GitHub App has multiple installations (${ids}); a repo must be specified to select one`)
|
|
123
132
|
}
|
|
124
133
|
const id = list[0]?.id
|
|
125
134
|
if (typeof id !== 'number') throw new Error('GitHub App installation missing id')
|
|
126
|
-
this.
|
|
135
|
+
this.soleInstallationId = id
|
|
127
136
|
return id
|
|
128
137
|
}
|
|
138
|
+
|
|
139
|
+
private async resolveInstallationByEndpoint(jwt: string, path: string, target: string): Promise<number> {
|
|
140
|
+
const cached = this.repoInstallationCache.get(target)
|
|
141
|
+
if (cached !== undefined) return cached
|
|
142
|
+
const response = await this.fetchImpl(`${GITHUB_API_BASE}/${path}`, { headers: githubJsonHeaders(jwt) })
|
|
143
|
+
if (response.status === 404) {
|
|
144
|
+
throw new Error(`GitHub App is not installed for ${target} or lacks access to that repository`)
|
|
145
|
+
}
|
|
146
|
+
if (!response.ok) throw new Error(`GitHub App installation lookup for ${target} failed: ${response.status}`)
|
|
147
|
+
const raw = (await response.json()) as { id?: unknown }
|
|
148
|
+
if (typeof raw.id !== 'number') throw new Error(`GitHub App installation for ${target} missing id`)
|
|
149
|
+
this.repoInstallationCache.set(target, raw.id)
|
|
150
|
+
return raw.id
|
|
151
|
+
}
|
|
129
152
|
}
|
|
130
153
|
|
|
131
154
|
function base64url(input: string | Buffer): string {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { resolveSecret, type Secret } from '@/secrets/resolve'
|
|
2
2
|
|
|
3
|
-
import type { GithubAuthStrategy, GithubSelfUser } from './auth'
|
|
3
|
+
import type { GithubAuthContext, GithubAuthStrategy, GithubSelfUser } from './auth'
|
|
4
4
|
|
|
5
5
|
export const GITHUB_API_BASE = 'https://api.github.com'
|
|
6
6
|
|
|
@@ -15,11 +15,11 @@ export class PatAuthStrategy implements GithubAuthStrategy {
|
|
|
15
15
|
this.fetchImpl = options.fetchImpl ?? fetch
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
async token(): Promise<string> {
|
|
18
|
+
async token(_context?: GithubAuthContext): Promise<string> {
|
|
19
19
|
return this._token
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
async authHeaders(): Promise<HeadersInit> {
|
|
22
|
+
async authHeaders(_context?: GithubAuthContext): Promise<HeadersInit> {
|
|
23
23
|
return githubJsonHeaders(this._token)
|
|
24
24
|
}
|
|
25
25
|
|
|
@@ -3,15 +3,30 @@ import type { GithubAppAuthBlock, GithubPatAuthBlock } from '@/secrets/schema'
|
|
|
3
3
|
import { AppAuthStrategy } from './auth-app'
|
|
4
4
|
import { PatAuthStrategy } from './auth-pat'
|
|
5
5
|
|
|
6
|
+
// Repo identity threaded through every auth call so App auth can pick the
|
|
7
|
+
// correct installation. `repoSlug` is the canonical input ("owner/name"); App
|
|
8
|
+
// auth resolves it to an installation via GET /repos/{owner}/{repo}/installation
|
|
9
|
+
// and caches the result. PAT auth ignores it entirely. Omitted context means
|
|
10
|
+
// "no specific repo" — App auth then falls back to a single discoverable
|
|
11
|
+
// installation (and errors if the App spans multiple installations).
|
|
12
|
+
export type GithubAuthContext = {
|
|
13
|
+
repoSlug?: string
|
|
14
|
+
// Org login, for operations that aren't repo-scoped (e.g. team-membership
|
|
15
|
+
// lookups under GET /orgs/{org}/...). App auth resolves it to an
|
|
16
|
+
// installation via GET /orgs/{org}/installation. Ignored when repoSlug is set.
|
|
17
|
+
owner?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
6
20
|
export type GithubAuthStrategy = {
|
|
7
|
-
token: () => Promise<string>
|
|
8
|
-
authHeaders: () => Promise<HeadersInit>
|
|
21
|
+
token: (context?: GithubAuthContext) => Promise<string>
|
|
22
|
+
authHeaders: (context?: GithubAuthContext) => Promise<HeadersInit>
|
|
9
23
|
getSelf: () => Promise<GithubSelfUser>
|
|
10
24
|
// App-only: returns the installation's granted-permissions map and declared
|
|
11
25
|
// events so the adapter can preflight against the configured eventAllowlist
|
|
12
26
|
// before any webhook arrives. PATs return access via token scopes, not an
|
|
13
|
-
// installation grant, so they leave this undefined.
|
|
14
|
-
|
|
27
|
+
// installation grant, so they leave this undefined. Context selects which
|
|
28
|
+
// installation to inspect when the App spans multiple owners.
|
|
29
|
+
getInstallationGrants?: (context?: GithubAuthContext) => Promise<GithubInstallationGrants>
|
|
15
30
|
dispose: () => Promise<void>
|
|
16
31
|
}
|
|
17
32
|
|
|
@@ -36,7 +51,6 @@ export function buildAuthStrategy(options: {
|
|
|
36
51
|
return new AppAuthStrategy({
|
|
37
52
|
appId: options.auth.appId,
|
|
38
53
|
privateKey: options.auth.privateKey,
|
|
39
|
-
installationId: options.auth.installationId,
|
|
40
54
|
fetchImpl: options.fetchImpl,
|
|
41
55
|
})
|
|
42
56
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { ChannelNameResolver, ResolvedChannelNames } from '@/channels/types'
|
|
2
2
|
|
|
3
|
+
import type { GithubAuthContext } from './auth'
|
|
3
4
|
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
4
5
|
import { parseChat, parseRepo } from './outbound'
|
|
5
6
|
|
|
6
7
|
export function createGithubChannelNameResolver(options: {
|
|
7
|
-
token: () => Promise<string>
|
|
8
|
+
token: (context?: GithubAuthContext) => Promise<string>
|
|
8
9
|
fetchImpl?: typeof fetch
|
|
9
10
|
}): ChannelNameResolver {
|
|
10
11
|
const fetchImpl = options.fetchImpl ?? fetch
|
|
@@ -18,7 +19,7 @@ export function createGithubChannelNameResolver(options: {
|
|
|
18
19
|
const path = chat.kind === 'issue' ? `issues/${chat.number}` : `pulls/${chat.number}`
|
|
19
20
|
try {
|
|
20
21
|
const response = await fetchImpl(`${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/${path}`, {
|
|
21
|
-
headers: githubJsonHeaders(await options.token()),
|
|
22
|
+
headers: githubJsonHeaders(await options.token({ repoSlug: key.workspace })),
|
|
22
23
|
})
|
|
23
24
|
if (!response.ok) return names
|
|
24
25
|
const raw = (await response.json()) as { title?: string }
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { ChannelHistoryMessage, FetchHistoryArgs, FetchHistoryResult, HistoryCallback } from '@/channels/types'
|
|
2
2
|
|
|
3
|
+
import type { GithubAuthContext } from './auth'
|
|
3
4
|
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
4
5
|
import { parseChat, parseRepo } from './outbound'
|
|
5
6
|
|
|
6
7
|
export function createGithubHistoryCallback(options: {
|
|
7
|
-
token: () => Promise<string>
|
|
8
|
+
token: (context?: GithubAuthContext) => Promise<string>
|
|
8
9
|
workspaceForChat: (chat: string) => string | null
|
|
9
10
|
fetchImpl?: typeof fetch
|
|
10
11
|
}): HistoryCallback {
|
|
@@ -26,7 +27,7 @@ export function createGithubHistoryCallback(options: {
|
|
|
26
27
|
const response = await fetchImpl(
|
|
27
28
|
`${endpoint}?per_page=${Math.min(Math.max(args.limit, 1), 100)}&direction=desc${cursor}`,
|
|
28
29
|
{
|
|
29
|
-
headers: githubJsonHeaders(await options.token()),
|
|
30
|
+
headers: githubJsonHeaders(await options.token({ repoSlug: workspace })),
|
|
30
31
|
},
|
|
31
32
|
)
|
|
32
33
|
if (!response.ok) return { ok: false, error: `GitHub history ${response.status}` }
|
|
@@ -13,8 +13,8 @@ export type GithubWebhookHandlerOptions = {
|
|
|
13
13
|
allowlist: () => readonly string[]
|
|
14
14
|
selfId: () => string | null
|
|
15
15
|
selfLogin: () => string | null
|
|
16
|
-
// Defaults to 'pat' when omitted.
|
|
17
|
-
//
|
|
16
|
+
// Defaults to 'pat' when omitted. In 'app' mode classifyReviewRequest also
|
|
17
|
+
// matches the App's decoy reviewer login; see resolveDecoyReviewerLogin.
|
|
18
18
|
authType?: () => 'pat' | 'app'
|
|
19
19
|
route: (message: InboundMessage) => void
|
|
20
20
|
logger: GithubInboundLogger
|
|
@@ -178,17 +178,10 @@ export function classifyGithubInbound(
|
|
|
178
178
|
number,
|
|
179
179
|
base,
|
|
180
180
|
selfLogin,
|
|
181
|
+
authType: options?.authType ?? 'pat',
|
|
181
182
|
teamIsBotMember: options?.teamIsBotMember,
|
|
182
183
|
})
|
|
183
184
|
}
|
|
184
|
-
// A GitHub App cannot be added to a PR's requested_reviewers, so it never
|
|
185
|
-
// receives a review_requested event targeting itself. The opened event is
|
|
186
|
-
// the only signal it can act on, so in App mode an opened PR is promoted to
|
|
187
|
-
// a review request. A PAT-backed bot is a real user that can be requested,
|
|
188
|
-
// so it waits for the explicit request instead of reviewing every PR.
|
|
189
|
-
if (action === 'opened' && options?.authType === 'app') {
|
|
190
|
-
return classifyOpenedAsReview({ payload, pr, number, base, selfLogin })
|
|
191
|
-
}
|
|
192
185
|
return buildInbound(
|
|
193
186
|
{ ...base, chat: `pr:${number}`, thread: null },
|
|
194
187
|
pr.body,
|
|
@@ -242,23 +235,46 @@ type ReviewRequestInput = {
|
|
|
242
235
|
number: number
|
|
243
236
|
base: Pick<InboundMessage, 'adapter' | 'workspace' | 'isDm' | 'mentionsOthers' | 'replyToOtherMessageId'>
|
|
244
237
|
selfLogin: string | null
|
|
238
|
+
authType: 'pat' | 'app'
|
|
245
239
|
teamIsBotMember: boolean | undefined
|
|
246
240
|
}
|
|
247
241
|
|
|
242
|
+
// A GitHub App can never be a `requested_reviewer` — that field only holds
|
|
243
|
+
// real user accounts, and the App actor (`slug[bot]`) is not one. The
|
|
244
|
+
// supported workaround is a decoy user account named after the App that an
|
|
245
|
+
// operator requests instead (see docs/content/docs/internals/github-decoy-reviewer.mdx).
|
|
246
|
+
// Its login is, by convention, the App slug — i.e. `selfLogin` with the
|
|
247
|
+
// `[bot]` suffix removed (`my-app[bot]` → `my-app`). This is the single seam
|
|
248
|
+
// where that login is resolved: when the decoy account's real login diverges
|
|
249
|
+
// from the slug, a future config field replaces this derivation without
|
|
250
|
+
// touching the matcher. PAT auth has no decoy (the bot IS a real user that can
|
|
251
|
+
// be requested directly), so it returns null.
|
|
252
|
+
const BOT_LOGIN_SUFFIX = '[bot]'
|
|
253
|
+
|
|
254
|
+
function resolveDecoyReviewerLogin(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
|
+
|
|
248
261
|
function classifyReviewRequest(input: ReviewRequestInput): InboundMessage | null {
|
|
249
|
-
const { action, payload, pr, number, base, selfLogin, teamIsBotMember } = input
|
|
262
|
+
const { action, payload, pr, number, base, selfLogin, authType, teamIsBotMember } = input
|
|
250
263
|
if (selfLogin === null) return null
|
|
264
|
+
const decoyLogin = resolveDecoyReviewerLogin(selfLogin, authType)
|
|
251
265
|
const sender = readUser(payload.sender)
|
|
252
266
|
if (sender === null) return null
|
|
253
|
-
// Self-loop guard: if the bot
|
|
267
|
+
// Self-loop guard: if the bot (or its decoy) requested/un-requested the
|
|
254
268
|
// review, drop the event. The bot adding itself as a reviewer would
|
|
255
269
|
// otherwise wake a fresh session every time it self-assigns.
|
|
256
|
-
if (sender.login === selfLogin) return null
|
|
270
|
+
if (sender.login === selfLogin || (decoyLogin !== null && sender.login === decoyLogin)) return null
|
|
257
271
|
|
|
258
272
|
const requestedUser = readUser(payload.requested_reviewer)
|
|
259
273
|
const requestedTeam = readReviewerTeam(payload.requested_team)
|
|
260
274
|
|
|
261
|
-
const isMeAsUser =
|
|
275
|
+
const isMeAsUser =
|
|
276
|
+
requestedUser !== null &&
|
|
277
|
+
(requestedUser.login === selfLogin || (decoyLogin !== null && requestedUser.login === decoyLogin))
|
|
262
278
|
const isMyTeam = requestedTeam !== null && teamIsBotMember === true
|
|
263
279
|
if (!isMeAsUser && !isMyTeam) return null
|
|
264
280
|
|
|
@@ -303,47 +319,6 @@ function classifyReviewRequest(input: ReviewRequestInput): InboundMessage | null
|
|
|
303
319
|
}
|
|
304
320
|
}
|
|
305
321
|
|
|
306
|
-
type OpenedAsReviewInput = {
|
|
307
|
-
payload: Record<string, unknown>
|
|
308
|
-
pr: Record<string, unknown>
|
|
309
|
-
number: number
|
|
310
|
-
base: Pick<InboundMessage, 'adapter' | 'workspace' | 'isDm' | 'mentionsOthers' | 'replyToOtherMessageId'>
|
|
311
|
-
selfLogin: string | null
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function classifyOpenedAsReview(input: OpenedAsReviewInput): InboundMessage | null {
|
|
315
|
-
const { payload, pr, number, base, selfLogin } = input
|
|
316
|
-
if (selfLogin === null) return null
|
|
317
|
-
const sender = readUser(payload.sender)
|
|
318
|
-
if (sender === null) return null
|
|
319
|
-
if (sender.login === selfLogin) return null
|
|
320
|
-
|
|
321
|
-
const title = readString(pr, 'title') ?? `#${number}`
|
|
322
|
-
const head = readString(readRecord(pr.head), 'ref')
|
|
323
|
-
const baseRef = readString(readRecord(pr.base), 'ref')
|
|
324
|
-
const branchSegment = head !== null && baseRef !== null ? ` Branch: ${head} → ${baseRef}.` : ''
|
|
325
|
-
const text =
|
|
326
|
-
`@${sender.login} requested your review on PR #${number}: "${title}".${branchSegment}` +
|
|
327
|
-
' Please review the changes line-by-line and post your feedback.'
|
|
328
|
-
|
|
329
|
-
const updatedAt = readString(pr, 'updated_at') ?? ''
|
|
330
|
-
const prId = readNumber(pr, 'id') ?? number
|
|
331
|
-
|
|
332
|
-
return {
|
|
333
|
-
...base,
|
|
334
|
-
chat: `pr:${number}`,
|
|
335
|
-
thread: null,
|
|
336
|
-
text,
|
|
337
|
-
externalMessageId: `pr-${prId}-opened-${updatedAt}`,
|
|
338
|
-
authorId: String(sender.id),
|
|
339
|
-
authorName: sender.login,
|
|
340
|
-
authorIsBot: sender.type === 'Bot',
|
|
341
|
-
isBotMention: true,
|
|
342
|
-
replyToBotMessageId: null,
|
|
343
|
-
ts: updatedAt !== '' ? Date.parse(updatedAt) || 0 : 0,
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
322
|
export type GithubReviewerTeam = { slug: string; id: number; org: string | null }
|
|
348
323
|
|
|
349
324
|
export function readReviewerTeam(value: unknown): GithubReviewerTeam | null {
|