Skip to content

Commit 4f066a5

Browse files
committed
fixes
1 parent acabc18 commit 4f066a5

8 files changed

Lines changed: 172 additions & 20 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ This section amends your scenario list and adds missing scenarios.
126126
- If signed out: user sees auth-required state and can start GitHub login.
127127
- If signed in and authorized on at least one installed account: user sees the account list and install button.
128128
- If signed in but authorized on zero installed accounts: user sees install CTA for GitHub App flow.
129+
- Newly installed accounts start with no CLA text. Maintainers must publish their own CLA before external contributors can sign.
129130

130131
### 3) User selects Contributor
131132

@@ -189,6 +190,7 @@ This section amends your scenario list and adds missing scenarios.
189190
### 12) Installation lifecycle scenarios
190191

191192
- Installation `created` or `unsuspend`: account row is created/reactivated, installation ID refreshed, and installation target metadata (`organization` vs `user`) is persisted.
193+
- New installations are initialized with empty CLA text and `cla_text_sha256 = null` (no built-in agreement/template is auto-published).
192194
- Installation `deleted` or `suspend`: account is deactivated and installation ID cleared.
193195
- Installation repository-change events refresh installation linkage.
194196

app/admin/[orgSlug]/actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { getBaseUrlFromHeaders } from "@/lib/cla/signing"
1010

1111
const updateClaSchema = z.object({
1212
orgSlug: z.string().min(1),
13-
claMarkdown: z.string().min(1, "CLA text cannot be empty"),
13+
claMarkdown: z.string().trim().min(1, "CLA text cannot be empty"),
1414
})
1515

1616
const toggleActiveSchema = z.object({

app/api/orgs/[orgSlug]/route.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ export async function PATCH(
7070
if (typeof claMarkdown !== "string") {
7171
return NextResponse.json({ error: "claMarkdown or isActive is required" }, { status: 400 })
7272
}
73+
if (claMarkdown.trim().length === 0) {
74+
return NextResponse.json({ error: "CLA text cannot be empty" }, { status: 400 })
75+
}
7376

7477
const org = await updateOrganizationCla(orgSlug, claMarkdown)
7578
if (!org) {

app/api/webhook/github/route.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,68 @@ async function handlePrCheck(params: {
380380
})
381381
}
382382

383+
if (!org.claTextSha256 || org.claText.trim().length === 0) {
384+
const check = await github.createCheckRun({
385+
owner: orgSlug,
386+
repo: repoName,
387+
name: CHECK_NAME,
388+
head_sha: headSha,
389+
status: "completed",
390+
conclusion: "failure",
391+
output: {
392+
title: "CLA: Configuration required",
393+
summary: `@${orgSlug} has not published a CLA yet. A maintainer must configure one before contributors can sign.`,
394+
},
395+
})
396+
397+
const commentBody = generateUnconfiguredClaComment({
398+
prAuthor,
399+
orgName: org.name,
400+
orgSlug: org.githubOrgSlug,
401+
appBaseUrl: baseUrl,
402+
})
403+
const existingComment = await github.findBotComment(orgSlug, repoName, prNumber)
404+
const comment = existingComment
405+
? await github.updateComment({
406+
owner: orgSlug,
407+
repo: repoName,
408+
comment_id: existingComment.id,
409+
body: commentBody,
410+
})
411+
: await github.createComment({
412+
owner: orgSlug,
413+
repo: repoName,
414+
issue_number: prNumber,
415+
body: commentBody,
416+
})
417+
418+
await createAuditEvent({
419+
eventType: "webhook.pr_check",
420+
orgId: org.id,
421+
actorGithubId: prAuthorId ? String(prAuthorId) : null,
422+
actorGithubUsername: prAuthor,
423+
payload: {
424+
owner: orgSlug,
425+
repo: repoName,
426+
prNumber,
427+
decision: "cla_unconfigured",
428+
checkConclusion: check.conclusion,
429+
commentId: comment.id,
430+
},
431+
})
432+
433+
return NextResponse.json({
434+
message: `CLA is not configured for ${orgSlug}. Check failed until maintainers publish one.`,
435+
check: { id: check.id, status: "failure", conclusion: check.conclusion },
436+
comment: { id: comment.id, commentMarkdown: comment.body },
437+
orgMember: false,
438+
accountOwner: false,
439+
signed: false,
440+
needsResign: false,
441+
configRequired: true,
442+
})
443+
}
444+
383445
const sigStatus =
384446
typeof prAuthorId === "number"
385447
? await getSignatureStatusByGithubId(orgSlug, String(prAuthorId))
@@ -693,3 +755,23 @@ function getBaseUrl(request: NextRequest): string {
693755
const url = new URL(request.url)
694756
return `${url.protocol}//${url.host}`
695757
}
758+
759+
function generateUnconfiguredClaComment(params: {
760+
prAuthor: string
761+
orgName: string
762+
orgSlug: string
763+
appBaseUrl: string
764+
}) {
765+
const { prAuthor, orgName, orgSlug, appBaseUrl } = params
766+
const adminUrl = `${appBaseUrl}/admin/${encodeURIComponent(orgSlug)}`
767+
768+
return `### CLA setup in progress
769+
770+
Hey @${prAuthor}, thanks for contributing to **${orgName}**.
771+
772+
This repository has not published a Contributor License Agreement yet, so we cannot validate signatures for external contributors at this time.
773+
774+
A maintainer must publish the CLA first: ${adminUrl}
775+
776+
<sub>Once the CLA is configured, this check will enforce contributor signing automatically.</sub>`
777+
}

components/admin/org-manage-client.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export function OrgManageClient({
8080
() => signers.filter((signature) => signature.claSha256 !== currentClaSha256),
8181
[signers, currentClaSha256]
8282
)
83+
const hasConfiguredCla = Boolean(currentClaSha256 && currentClaMarkdown.trim().length > 0)
8384

8485
function handleSave() {
8586
startTransition(async () => {
@@ -221,7 +222,7 @@ export function OrgManageClient({
221222
<Card>
222223
<CardContent className="py-4 text-center">
223224
<p className="font-mono text-2xl font-bold text-foreground" data-testid="version-count">
224-
{currentClaSha256 ? currentClaSha256.slice(0, 7) : "---"}
225+
{currentClaSha256 ? currentClaSha256.slice(0, 7) : "unset"}
225226
</p>
226227
<p className="text-xs text-muted-foreground">CLA Version</p>
227228
</CardContent>
@@ -305,8 +306,9 @@ export function OrgManageClient({
305306
)}
306307
</CardTitle>
307308
<CardDescription>
308-
This is the agreement contributors must sign before their PRs are accepted. Saving
309-
creates a new version; existing signers will need to re-sign.
309+
{hasConfiguredCla
310+
? "This is the agreement contributors must sign before their PRs are accepted. Saving creates a new version; existing signers will need to re-sign."
311+
: "No CLA is configured yet. Publish your own CLA below to start enforcement."}
310312
</CardDescription>
311313
</div>
312314
{!isEditing ? (
@@ -365,7 +367,14 @@ export function OrgManageClient({
365367
/>
366368
) : (
367369
<div className="rounded-lg border bg-background p-6" data-testid="cla-preview">
368-
<MarkdownRenderer content={claContent} />
370+
{hasConfiguredCla ? (
371+
<MarkdownRenderer content={claContent} />
372+
) : (
373+
<p className="text-sm text-muted-foreground">
374+
No CLA configured yet. Click <strong>Edit</strong> and paste your own CLA in
375+
Markdown.
376+
</p>
377+
)}
369378
</div>
370379
)}
371380
</CardContent>

components/sign/sign-cla-client.tsx

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,9 @@ export function SignClaClient({
6767
const [actionError, setActionError] = useState<string | null>(null)
6868
const [isPending, startTransition] = useTransition()
6969

70+
const hasConfiguredCla = Boolean(currentSha256 && org.claMarkdown.trim().length > 0)
7071
const signed = alreadySigned || justSigned
71-
const showSignAction = !signed
72+
const showSignAction = !signed && hasConfiguredCla
7273

7374
const signedBannerText = useMemo(() => {
7475
if (existingSignature && !justSigned) {
@@ -90,7 +91,7 @@ export function SignClaClient({
9091
}, [])
9192

9293
function handleSign() {
93-
if (!currentSha256) return
94+
if (!currentSha256 || !hasConfiguredCla) return
9495

9596
startTransition(async () => {
9697
setActionError(null)
@@ -142,6 +143,19 @@ export function SignClaClient({
142143
</div>
143144
)}
144145

146+
{!hasConfiguredCla && (
147+
<div className="mb-6 flex items-center gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 px-5 py-4">
148+
<AlertTriangle className="h-5 w-5 shrink-0 text-amber-500" />
149+
<div>
150+
<p className="text-sm font-medium text-foreground">CLA not configured yet</p>
151+
<p className="text-xs text-muted-foreground">
152+
A maintainer needs to publish this organization&apos;s CLA before contributors can
153+
sign.
154+
</p>
155+
</div>
156+
</div>
157+
)}
158+
145159
{needsResign && !justSigned && (
146160
<div
147161
className="mb-6 flex items-center gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 px-5 py-4"
@@ -192,18 +206,26 @@ export function SignClaClient({
192206
)}
193207
</CardTitle>
194208
<CardDescription>
195-
Please read the full agreement below.
209+
{hasConfiguredCla
210+
? "Please read the full agreement below."
211+
: "No CLA has been published for this organization yet."}
196212
{showSignAction && " Scroll to the bottom to enable signing."}
197213
</CardDescription>
198214
</CardHeader>
199215
<CardContent>
200-
<div
201-
className="max-h-[500px] overflow-y-auto rounded-lg border bg-background p-6"
202-
onScroll={handleScroll}
203-
data-testid="cla-scroll-area"
204-
>
205-
<MarkdownRenderer content={org.claMarkdown} />
206-
</div>
216+
{hasConfiguredCla ? (
217+
<div
218+
className="max-h-[500px] overflow-y-auto rounded-lg border bg-background p-6"
219+
onScroll={handleScroll}
220+
data-testid="cla-scroll-area"
221+
>
222+
<MarkdownRenderer content={org.claMarkdown} />
223+
</div>
224+
) : (
225+
<div className="rounded-lg border border-dashed border-border bg-background/40 p-6 text-sm text-muted-foreground">
226+
This page will show the agreement once a maintainer adds it in the admin dashboard.
227+
</div>
228+
)}
207229
</CardContent>
208230
</Card>
209231

lib/db/queries.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -189,8 +189,6 @@ export async function createOrganization(data: {
189189
installationId?: number
190190
}) {
191191
const db = await ensureDbReady()
192-
const { DEFAULT_CLA_MARKDOWN } = await import("./seed")
193-
const hash = await sha256Hex(DEFAULT_CLA_MARKDOWN)
194192

195193
const rows = await db
196194
.insert(organizations)
@@ -208,8 +206,8 @@ export async function createOrganization(data: {
208206
adminUserId: data.adminUserId,
209207
isActive: true,
210208
installationId: data.installationId ?? null,
211-
claText: DEFAULT_CLA_MARKDOWN,
212-
claTextSha256: hash,
209+
claText: "",
210+
claTextSha256: null,
213211
})
214212
.returning()
215213

tests/integration/api-suite.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1282,15 +1282,51 @@ test("Webhook: missing pull_request payload fields returns 400", async (baseUrl)
12821282

12831283
test("Webhook: installation created registers new org", async (baseUrl) => {
12841284
await resetDb(baseUrl)
1285-
const { data } = await sendWebhook(baseUrl, "installation", {
1285+
const { res, data } = await sendWebhook(baseUrl, "installation", {
12861286
action: "created",
12871287
installation: { account: { login: "new-org" } },
1288+
sender: { id: 1001, login: "orgadmin" },
12881289
})
1290+
assertEqual(res.status, 200, "status")
12891291
assert(data.message.includes("new-org"), "message references org")
12901292
assert(data.org !== undefined, "org object returned")
1293+
assertEqual(data.org.claText, "", "no built-in CLA text")
1294+
assertEqual(data.org.claTextSha256, null, "no built-in CLA hash")
12911295

12921296
const orgRes = await fetch(`${baseUrl}/api/orgs/new-org`)
1297+
const orgData = await orgRes.json()
12931298
assertEqual(orgRes.status, 200, "new org accessible")
1299+
assertEqual(orgData.currentClaMarkdown, "", "org details expose empty CLA")
1300+
assertEqual(orgData.currentClaSha256, null, "org details expose null CLA hash")
1301+
})
1302+
1303+
test("Webhook: non-member PR on org without configured CLA fails with config required", async (baseUrl) => {
1304+
await resetDb(baseUrl)
1305+
await sendWebhook(baseUrl, "installation", {
1306+
action: "created",
1307+
installation: {
1308+
id: 33001,
1309+
account: { login: "new-org", id: 3301, type: "Organization" },
1310+
},
1311+
sender: { id: 1001, login: "orgadmin" },
1312+
})
1313+
1314+
const { res, data } = await sendWebhook(
1315+
baseUrl,
1316+
"pull_request",
1317+
makePrPayload({
1318+
action: "opened",
1319+
prAuthor: "new-contributor",
1320+
orgSlug: "new-org",
1321+
repoName: "starter-kit",
1322+
prNumber: 7,
1323+
})
1324+
)
1325+
assertEqual(res.status, 200, "status")
1326+
assertEqual(data.check.status, "failure", "check fails")
1327+
assertEqual(data.configRequired, true, "config required flag set")
1328+
assert(data.comment !== null, "bot comment posted")
1329+
assert(data.comment.commentMarkdown.includes("not published"), "comment explains missing CLA")
12941330
})
12951331

12961332
test("Webhook: personal-account installation stores user target metadata", async (baseUrl) => {

0 commit comments

Comments
 (0)