Skip to content

Commit 48f9a69

Browse files
committed
fix
1 parent 18ecd13 commit 48f9a69

14 files changed

Lines changed: 1621 additions & 172 deletions

File tree

README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ It gives org admins a place to manage CLA text and signing history, and gives co
88

99
- If a contributor has signed a non-current CLA version, they must re-sign before being considered compliant.
1010
- Contributor compliance status is evaluated per org using the contributor's latest signed version for that org.
11-
- Admins can define an org-scoped bypass list of GitHub accounts that should always receive a passing CLA check.
11+
- Admins can define org-scoped bypass lists for both GitHub users and GitHub Apps/system bots that should always receive a passing CLA check.
1212
- If a contributor has open pull requests and their signature becomes outdated after a CLA update, checks may need to be re-opened/re-evaluated and set to failing until re-signing is completed.
1313
- After a contributor signs/re-signs the latest CLA, the app schedules an async workflow that updates their open PR CLA checks to success and removes stale CLA prompt comments.
1414
- When an org is activated/deactivated, the app schedules an async workflow to re-check open PRs for that org so checks converge to the new enforcement mode.
@@ -110,7 +110,7 @@ This section is the behavior contract for UI routes.
110110
| `/auth/signin` | Start sign-in flow | Shows GitHub sign-in CTA | Same | Sends user to `/api/auth/github?returnTo=...`; `returnTo` is sanitized to internal paths only |
111111
| `/dashboard` | Mode selector page | Public page | Same + session shown in header | Navigate to `/admin` or `/contributor` |
112112
| `/admin` | Org admin overview | Shows "Sign in required" card | Lists organizations user can administer; shows install CTA when none are authorized | Install app (`/api/github/install`), open org manage pages |
113-
| `/admin/[orgSlug]` | Org CLA management | If data unavailable, shows "Organization not found" UI | Shows org details, CLA version, signers, archives, bypass list, branch-protection reminder | Edit/save CLA text with live markdown preview modes (`Edit`, `Split`, `Preview`), activate/deactivate bot, copy signing link, inspect signers/archives, manage bypass usernames, download current/archived CLA text, share tab links via `?tab=cla|signers|archives|bypass` |
113+
| `/admin/[orgSlug]` | Org CLA management | If data unavailable, shows "Organization not found" UI | Shows org details, CLA version, signers, archives, bypass list, branch-protection reminder | Edit/save CLA text with live markdown preview modes (`Edit`, `Split`, `Preview`), activate/deactivate bot, copy signing link, inspect signers/archives, manage bypass users and app/bot slugs, download current/archived CLA text, share tab links via `?tab=cla|signers|archives|bypass` |
114114
| `/contributor` | Contributor agreement dashboard | Shows "Sign in required" card | Lists signed CLA history grouped by org status | Re-sign prompts for outdated orgs, links to `/sign/[orgSlug]`, download previously signed CLA records |
115115
| `/sign/[orgSlug]` | CLA read/sign page | Shows sign-in required (or org not found) | Shows signed state, or sign/re-sign workflow | Requires scroll-to-bottom before sign button enables; handles inactive org warning |
116116
| `/terms` | Legal terms page | Public page | Same | Documents signing/enforcement terms and branch-protection requirement |
@@ -156,7 +156,7 @@ This section amends your scenario list and adds missing scenarios.
156156

157157
- Org member: check passes, no CLA comment.
158158
- Personal-account repository owner: check passes, no CLA comment.
159-
- User on org bypass list: check passes, no CLA comment.
159+
- User or app/bot on org bypass list: check passes, no CLA comment.
160160
- Non-member + current signature: check passes, no CLA comment.
161161
- Non-member + outdated signature: check fails, re-sign comment posted.
162162
- Non-member + never signed: check fails, sign prompt comment posted.
@@ -182,7 +182,7 @@ This section amends your scenario list and adds missing scenarios.
182182

183183
- Org deactivated/uninstalled: signing blocked; webhook events set passing CLA checks and remove managed CLA prompts so PRs are not blocked by CLA while inactive.
184184
- Activating or deactivating an org schedules an async open-PR recheck workflow so existing PR checks/comments converge automatically.
185-
- Updating bypass list schedules async open-PR recheck so existing PRs converge to the latest policy.
185+
- Updating either bypass section (users or app/bots) schedules async open-PR recheck so existing PRs converge to the latest policy.
186186
- `/recheck` authorization: allowed for PR author, org member, or maintainer; unauthorized users are blocked.
187187
- OAuth and install redirects sanitize `returnTo` to prevent open redirects.
188188
- Webhook hardening: production signature verification and delivery de-duplication.
@@ -227,9 +227,10 @@ This section amends your scenario list and adds missing scenarios.
227227
- Contributor dashboard status uses the latest stored signature per org to determine current/outdated state in UI.
228228
- Outcomes:
229229
- Org member: passing check, no CLA comment.
230-
- Bypass-listed account: passing check, no CLA comment.
230+
- Bypass-listed user/app/bot: passing check, no CLA comment.
231231
- Signed current CLA: passing check, no CLA comment.
232232
- Unsigned/outdated signature: failing check + bot comment with signing URL.
233+
- App/bot bypass matching is slug-based and treats `<slug>` and `<slug>[bot]` as equivalent actor forms.
233234
- When CLA text changes, contributors on older signatures are marked as requiring re-sign; open PRs may require check re-evaluation and failure until re-signing.
234235
- After signing/re-signing, an async workflow updates signer-authored open PR CLA checks to success and removes stale CLA prompt comments.
235236
- Activating/deactivating CLA enforcement schedules async open-PR rechecks; inactive mode converges CLA checks to success and clears managed CLA prompt comments.

app/admin/[orgSlug]/actions.ts

Lines changed: 94 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,17 @@ import {
88
addBypassAccount,
99
countBypassAccountsByOrg,
1010
createAuditEvent,
11+
getBypassAccountByOrgAndActorSlug,
1112
getBypassAccountByOrgAndGithubId,
13+
getBypassAccountByOrgAndGithubUsername,
1214
removeBypassAccount,
1315
setOrganizationActive,
1416
updateOrganizationCla,
1517
} from "@/lib/db/queries"
1618
import { authorizeOrgAccess } from "@/lib/server/org-access"
1719
import { getBaseUrlFromHeaders } from "@/lib/cla/signing"
1820
import { runClaRecheckWorkflow } from "@/workflows/cla-recheck"
21+
import { formatBypassActorLogin, normalizeBypassActorSlug } from "@/lib/bypass"
1922

2023
const MAX_ORG_BYPASS_ACCOUNTS = 50
2124

@@ -31,13 +34,28 @@ const toggleActiveSchema = z.object({
3134

3235
const addBypassSchema = z.object({
3336
orgSlug: z.string().min(1),
37+
})
38+
39+
const addUserBypassSchema = addBypassSchema.extend({
40+
bypassKind: z.literal("user"),
3441
githubUserId: z.string().trim().min(1, "GitHub user is required"),
3542
githubUsername: z.string().trim().min(1, "GitHub username is required"),
3643
})
3744

45+
const addAppBotBypassSchema = addBypassSchema.extend({
46+
bypassKind: z.literal("app_bot"),
47+
actorSlug: z.string().trim().min(1, "App or bot slug is required"),
48+
githubUsername: z.string().trim().optional(),
49+
})
50+
51+
const addBypassInputSchema = z.discriminatedUnion("bypassKind", [
52+
addUserBypassSchema,
53+
addAppBotBypassSchema,
54+
])
55+
3856
const removeBypassSchema = z.object({
3957
orgSlug: z.string().min(1),
40-
githubUserId: z.string().trim().min(1, "GitHub user ID is required"),
58+
bypassAccountId: z.string().trim().min(1, "Bypass account ID is required"),
4159
})
4260

4361
type ActionResult = {
@@ -152,33 +170,88 @@ export async function toggleOrganizationActiveAction(input: unknown): Promise<Ac
152170
}
153171

154172
export async function addBypassAccountAction(input: unknown): Promise<ActionResult> {
155-
const parsed = addBypassSchema.safeParse(input)
173+
const parsed = addBypassInputSchema.safeParse(input)
156174
if (!parsed.success) {
157175
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" }
158176
}
159177

160-
const { orgSlug, githubUserId, githubUsername } = parsed.data
178+
const { orgSlug } = parsed.data
161179
const access = await authorizeOrgAccess(orgSlug)
162180
if (!access.ok) {
163181
return { ok: false, error: access.message }
164182
}
165183

166-
const existing = await getBypassAccountByOrgAndGithubId(access.org.id, githubUserId)
167-
if (existing) {
168-
return { ok: false, error: `@${existing.githubUsername} is already on the bypass list` }
169-
}
170-
171184
const count = await countBypassAccountsByOrg(access.org.id)
172185
if (count >= MAX_ORG_BYPASS_ACCOUNTS) {
173186
return { ok: false, error: `Bypass list limit reached (${MAX_ORG_BYPASS_ACCOUNTS})` }
174187
}
175188

176-
const created = await addBypassAccount({
177-
orgId: access.org.id,
178-
githubUserId,
179-
githubUsername,
180-
createdByUserId: access.user.id,
181-
})
189+
let created: Awaited<ReturnType<typeof addBypassAccount>> | undefined | null = null
190+
let payload: Record<string, unknown> = {}
191+
192+
if (parsed.data.bypassKind === "user") {
193+
const { githubUserId, githubUsername } = parsed.data
194+
const existingById = await getBypassAccountByOrgAndGithubId(access.org.id, githubUserId, "user")
195+
if (existingById) {
196+
return { ok: false, error: `@${existingById.githubUsername} is already on the bypass list` }
197+
}
198+
199+
const existingByUsername = await getBypassAccountByOrgAndGithubUsername(
200+
access.org.id,
201+
githubUsername,
202+
"user"
203+
)
204+
if (existingByUsername) {
205+
return {
206+
ok: false,
207+
error: `@${existingByUsername.githubUsername} is already on the bypass list`,
208+
}
209+
}
210+
211+
created = await addBypassAccount({
212+
orgId: access.org.id,
213+
bypassKind: "user",
214+
githubUserId,
215+
githubUsername,
216+
createdByUserId: access.user.id,
217+
})
218+
payload = {
219+
bypassKind: "user",
220+
githubUserId,
221+
githubUsername,
222+
}
223+
} else {
224+
const normalizedActorSlug = normalizeBypassActorSlug(parsed.data.actorSlug)
225+
if (!normalizedActorSlug) {
226+
return { ok: false, error: "App or bot slug is required" }
227+
}
228+
const actorLogin = formatBypassActorLogin(normalizedActorSlug)
229+
230+
const existingByActorSlug = await getBypassAccountByOrgAndActorSlug(
231+
access.org.id,
232+
normalizedActorSlug
233+
)
234+
if (existingByActorSlug) {
235+
return {
236+
ok: false,
237+
error: `@${existingByActorSlug.githubUsername} is already on the app/bot bypass list`,
238+
}
239+
}
240+
241+
created = await addBypassAccount({
242+
orgId: access.org.id,
243+
bypassKind: "app_bot",
244+
actorSlug: normalizedActorSlug,
245+
githubUsername: actorLogin,
246+
createdByUserId: access.user.id,
247+
})
248+
payload = {
249+
bypassKind: "app_bot",
250+
actorSlug: normalizedActorSlug,
251+
githubUsername: actorLogin,
252+
}
253+
}
254+
182255
if (!created) {
183256
return { ok: false, error: "Bypass account already exists" }
184257
}
@@ -204,8 +277,8 @@ export async function addBypassAccountAction(input: unknown): Promise<ActionResu
204277
actorGithubId: access.user.githubId ?? null,
205278
actorGithubUsername: access.user.githubUsername,
206279
payload: {
207-
githubUserId,
208-
githubUsername,
280+
bypassAccountId: created.id,
281+
...payload,
209282
recheckScheduled: recheck.recheckScheduled,
210283
recheckRunId: recheck.recheckRunId,
211284
recheckScheduleError: recheck.recheckScheduleError,
@@ -224,15 +297,15 @@ export async function removeBypassAccountAction(input: unknown): Promise<ActionR
224297
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" }
225298
}
226299

227-
const { orgSlug, githubUserId } = parsed.data
300+
const { orgSlug, bypassAccountId } = parsed.data
228301
const access = await authorizeOrgAccess(orgSlug)
229302
if (!access.ok) {
230303
return { ok: false, error: access.message }
231304
}
232305

233306
const removed = await removeBypassAccount({
234307
orgId: access.org.id,
235-
githubUserId,
308+
bypassAccountId,
236309
})
237310
if (!removed) {
238311
return { ok: false, error: "Bypass account not found" }
@@ -259,6 +332,9 @@ export async function removeBypassAccountAction(input: unknown): Promise<ActionR
259332
actorGithubId: access.user.githubId ?? null,
260333
actorGithubUsername: access.user.githubUsername,
261334
payload: {
335+
bypassAccountId: removed.id,
336+
bypassKind: removed.bypassKind,
337+
actorSlug: removed.actorSlug,
262338
githubUserId: removed.githubUserId,
263339
githubUsername: removed.githubUsername,
264340
recheckScheduled: recheck.recheckScheduled,

app/api/admin/orgs/[orgSlug]/bypass/suggest/route.ts

Lines changed: 102 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ import { getBypassAccountsByOrg } from "@/lib/db/queries"
33
import { searchGitHubUsersWithOAuth } from "@/lib/github/oauth-user-search"
44
import { authorizeOrgAccess } from "@/lib/server/org-access"
55
import { decryptSecret } from "@/lib/security/encryption"
6+
import {
7+
formatBypassActorLogin,
8+
isLikelyAppBotActor,
9+
normalizeBypassActorSlug,
10+
parseBypassKind,
11+
} from "@/lib/bypass"
612

713
const MIN_QUERY_LENGTH = 2
814
const MAX_SUGGESTIONS = 8
@@ -18,38 +24,120 @@ export async function GET(
1824
}
1925

2026
const query = request.nextUrl.searchParams.get("q")?.trim() ?? ""
27+
const bypassKind = parseBypassKind(request.nextUrl.searchParams.get("kind")) ?? "user"
2128
if (query.length < MIN_QUERY_LENGTH) {
2229
return NextResponse.json({ suggestions: [] })
2330
}
2431

32+
const bypassAccounts = await getBypassAccountsByOrg(access.org.id)
33+
34+
const appBotBypassedActorSlugs = new Set(
35+
bypassAccounts
36+
.filter((entry) => entry.bypassKind === "app_bot")
37+
.map((entry) => normalizeBypassActorSlug(entry.actorSlug ?? entry.githubUsername))
38+
.filter(Boolean)
39+
)
40+
41+
const toManualAppBotSuggestion = () => {
42+
const actorSlug = normalizeBypassActorSlug(query)
43+
if (!actorSlug) return null
44+
return {
45+
kind: "app_bot" as const,
46+
actorSlug,
47+
githubUsername: formatBypassActorLogin(actorSlug),
48+
avatarUrl: "",
49+
type: "Bot" as const,
50+
alreadyBypassed: appBotBypassedActorSlugs.has(actorSlug),
51+
source: "manual" as const,
52+
}
53+
}
54+
2555
const encryptedToken = access.user.githubAccessTokenEncrypted ?? null
2656
const accessToken = encryptedToken ? decryptSecret(encryptedToken) : null
2757
if (!accessToken) {
58+
if (bypassKind === "app_bot") {
59+
const manualSuggestion = toManualAppBotSuggestion()
60+
return NextResponse.json({ suggestions: manualSuggestion ? [manualSuggestion] : [] })
61+
}
62+
2863
return NextResponse.json(
2964
{ error: "Missing GitHub OAuth token. Sign out and sign back in to enable autocomplete." },
3065
{ status: 400 }
3166
)
3267
}
3368

3469
try {
35-
const [suggestions, bypassAccounts] = await Promise.all([
36-
searchGitHubUsersWithOAuth({
37-
accessToken,
38-
query,
39-
limit: MAX_SUGGESTIONS,
40-
}),
41-
getBypassAccountsByOrg(access.org.id),
42-
])
43-
44-
const bypassIds = new Set(bypassAccounts.map((entry) => entry.githubUserId))
70+
const suggestions = await searchGitHubUsersWithOAuth({
71+
accessToken,
72+
query,
73+
limit: MAX_SUGGESTIONS,
74+
})
75+
76+
if (bypassKind === "user") {
77+
const bypassIds = new Set(
78+
bypassAccounts
79+
.filter((entry) => entry.bypassKind === "user")
80+
.map((entry) => entry.githubUserId)
81+
.filter((value): value is string => Boolean(value))
82+
)
83+
84+
return NextResponse.json({
85+
suggestions: suggestions
86+
.filter((item) => item.type !== "Bot")
87+
.map((item) => ({
88+
kind: "user" as const,
89+
...item,
90+
alreadyBypassed: bypassIds.has(item.githubUserId),
91+
})),
92+
})
93+
}
94+
95+
const appBotSuggestionMap = new Map<
96+
string,
97+
{
98+
kind: "app_bot"
99+
actorSlug: string
100+
githubUsername: string
101+
avatarUrl: string
102+
type: "Bot"
103+
alreadyBypassed: boolean
104+
source: "github" | "manual"
105+
}
106+
>()
107+
108+
for (const item of suggestions) {
109+
if (!isLikelyAppBotActor({ login: item.githubUsername, type: item.type })) continue
110+
111+
const actorSlug = normalizeBypassActorSlug(item.githubUsername)
112+
if (!actorSlug) continue
113+
114+
appBotSuggestionMap.set(actorSlug, {
115+
kind: "app_bot",
116+
actorSlug,
117+
githubUsername: formatBypassActorLogin(actorSlug),
118+
avatarUrl: item.avatarUrl,
119+
type: "Bot",
120+
alreadyBypassed: appBotBypassedActorSlugs.has(actorSlug),
121+
source: "github",
122+
})
123+
}
124+
125+
const manualSuggestion = toManualAppBotSuggestion()
126+
if (manualSuggestion && !appBotSuggestionMap.has(manualSuggestion.actorSlug)) {
127+
appBotSuggestionMap.set(manualSuggestion.actorSlug, manualSuggestion)
128+
}
129+
45130
return NextResponse.json({
46-
suggestions: suggestions.map((item) => ({
47-
...item,
48-
alreadyBypassed: bypassIds.has(item.githubUserId),
49-
})),
131+
suggestions: Array.from(appBotSuggestionMap.values()).slice(0, MAX_SUGGESTIONS),
50132
})
51133
} catch (error) {
52134
console.error("GitHub bypass autocomplete failed:", error)
135+
if (bypassKind === "app_bot") {
136+
const manualSuggestion = toManualAppBotSuggestion()
137+
if (manualSuggestion) {
138+
return NextResponse.json({ suggestions: [manualSuggestion] })
139+
}
140+
}
53141
return NextResponse.json({ error: "Failed to fetch GitHub user suggestions" }, { status: 502 })
54142
}
55143
}

0 commit comments

Comments
 (0)