Skip to content

Commit 068a8d8

Browse files
committed
fix
1 parent 1bfc6d8 commit 068a8d8

8 files changed

Lines changed: 526 additions & 173 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ It gives org admins a place to manage CLA text and signing history, and gives co
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.
1111
- 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.
12-
- After a contributor signs/re-signs the latest CLA, the bot updates their open PR CLA checks back to success and removes stale CLA prompt comments.
12+
- 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.
1313
- GitHub is the user-management source of truth for the app.
1414
- The app has no local signup/password user-management system; DB user rows are GitHub-linked identity mirrors only.
1515
- Authentication/session management is stateless JWT-based (HTTP-only cookie + signed JWT with `jti`).
@@ -160,7 +160,7 @@ This section amends your scenario list and adds missing scenarios.
160160

161161
- Signature is stored with org, user, full CLA hash, accepted hash, assent metadata, immutable GitHub ID at signing time, timestamp, email provenance, and session evidence fields.
162162
- If `repo` + `pr` is provided, the signer must match that PR author before targeted PR sync is applied.
163-
- After signing/re-signing, open PRs authored by that contributor in the org are auto-synced: the latest CLA check run is updated to success and stale CLA prompt comments are deleted.
163+
- After signing/re-signing, the app schedules an async workflow to sync open PRs authored by that contributor in the org: latest CLA check runs are updated to success and stale CLA prompt comments are deleted.
164164

165165
### 7) Signed CLA versions cannot be deleted
166166

@@ -223,7 +223,7 @@ This section amends your scenario list and adds missing scenarios.
223223
- Signed current CLA: passing check, no CLA comment.
224224
- Unsigned/outdated signature: failing check + bot comment with signing URL.
225225
- 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.
226-
- After signing/re-signing, CLA checks on signer-authored open PRs are auto-updated to success and stale CLA prompt comments are removed.
226+
- After signing/re-signing, an async workflow updates signer-authored open PR CLA checks to success and removes stale CLA prompt comments.
227227
- Repository maintainers must require `CLA Bot / Contributor License Agreement` in GitHub branch protection/rulesets for merge blocking to be enforced.
228228

229229
## End-to-End Test Coverage Around This Spec

app/api/sign/route.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
import { type NextRequest, NextResponse } from "next/server"
22
import { getSessionUser } from "@/lib/auth"
3-
import {
4-
SignClaError,
5-
getBaseUrlFromHeaders,
6-
resolveRequestEvidenceFromHeaders,
7-
signClaForUser,
8-
} from "@/lib/cla/signing"
3+
import { scheduleSignerPrSyncAfterSign } from "@/lib/cla/signer-pr-sync-scheduler"
4+
import { SignClaError, resolveRequestEvidenceFromHeaders, signClaForUser } from "@/lib/cla/signing"
95

106
export async function POST(request: NextRequest) {
117
const body = await request.json()
@@ -33,10 +29,20 @@ export async function POST(request: NextRequest) {
3329
assented,
3430
consentTextVersion,
3531
requestEvidence: resolveRequestEvidenceFromHeaders(request.headers),
36-
appBaseUrl: getBaseUrlFromHeaders(request.headers),
32+
})
33+
const scheduleResult = await scheduleSignerPrSyncAfterSign({
34+
signResult: result,
35+
actor: {
36+
userId: user.id,
37+
githubId: user.githubId ?? null,
38+
githubUsername: user.githubUsername ?? null,
39+
},
3740
})
3841

39-
return NextResponse.json(result)
42+
return NextResponse.json({
43+
signature: result.signature,
44+
...scheduleResult,
45+
})
4046
} catch (error) {
4147
if (error instanceof SignClaError) {
4248
return NextResponse.json(

app/sign/[orgSlug]/actions.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,8 @@ import { headers } from "next/headers"
44
import { revalidatePath } from "next/cache"
55
import { z } from "zod"
66
import { getSessionUser } from "@/lib/auth"
7-
import {
8-
SignClaError,
9-
getBaseUrlFromHeaders,
10-
resolveRequestEvidenceFromHeaders,
11-
signClaForUser,
12-
} from "@/lib/cla/signing"
7+
import { scheduleSignerPrSyncAfterSign } from "@/lib/cla/signer-pr-sync-scheduler"
8+
import { SignClaError, resolveRequestEvidenceFromHeaders, signClaForUser } from "@/lib/cla/signing"
139

1410
const signSchema = z.object({
1511
orgSlug: z.string().min(1),
@@ -22,6 +18,10 @@ type SignActionResult = {
2218
ok: boolean
2319
error?: string
2420
currentSha256?: string
21+
prSyncScheduled?: boolean
22+
prSyncRunId?: string | null
23+
prSyncScheduleError?: string | null
24+
prSyncSkippedReason?: string | null
2525
}
2626

2727
export async function signClaAction(input: unknown): Promise<SignActionResult> {
@@ -41,19 +41,26 @@ export async function signClaAction(input: unknown): Promise<SignActionResult> {
4141
const headerStore = await headers()
4242

4343
try {
44-
await signClaForUser({
44+
const result = await signClaForUser({
4545
...parsed.data,
4646
user,
4747
assented: true,
4848
consentTextVersion: "v1",
4949
requestEvidence: resolveRequestEvidenceFromHeaders(headerStore),
50-
appBaseUrl: getBaseUrlFromHeaders(headerStore),
50+
})
51+
const scheduleResult = await scheduleSignerPrSyncAfterSign({
52+
signResult: result,
53+
actor: {
54+
userId: user.id,
55+
githubId: user.githubId ?? null,
56+
githubUsername: user.githubUsername ?? null,
57+
},
5158
})
5259

5360
revalidatePath(`/sign/${parsed.data.orgSlug}`)
5461
revalidatePath("/contributor")
5562

56-
return { ok: true }
63+
return { ok: true, ...scheduleResult }
5764
} catch (error) {
5865
if (error instanceof SignClaError) {
5966
return {

components/sign/sign-cla-client.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ export function SignClaClient({
6565
const [justSigned, setJustSigned] = useState(false)
6666
const [scrolledToBottom, setScrolledToBottom] = useState(false)
6767
const [actionError, setActionError] = useState<string | null>(null)
68+
const [postSignNotice, setPostSignNotice] = useState<{
69+
tone: "info" | "warning"
70+
message: string
71+
} | null>(null)
6872
const [isPending, startTransition] = useTransition()
6973

7074
const hasConfiguredCla = Boolean(currentSha256 && org.claMarkdown.trim().length > 0)
@@ -95,6 +99,7 @@ export function SignClaClient({
9599

96100
startTransition(async () => {
97101
setActionError(null)
102+
setPostSignNotice(null)
98103
const result = await signClaAction({
99104
orgSlug,
100105
repoName,
@@ -107,6 +112,25 @@ export function SignClaClient({
107112
return
108113
}
109114

115+
if (result.prSyncScheduled) {
116+
setPostSignNotice({
117+
tone: "info",
118+
message: "Signed. Open PR checks will update automatically in the background.",
119+
})
120+
} else if (result.prSyncScheduleError) {
121+
setPostSignNotice({
122+
tone: "warning",
123+
message:
124+
"Signed, but background PR sync could not be scheduled. Use /recheck on open PRs if needed.",
125+
})
126+
} else if (result.prSyncSkippedReason === "missing_installation_id") {
127+
setPostSignNotice({
128+
tone: "warning",
129+
message:
130+
"Signed. PR sync was skipped because this org has no active GitHub installation.",
131+
})
132+
}
133+
110134
setJustSigned(true)
111135
router.refresh()
112136
})
@@ -275,6 +299,18 @@ export function SignClaClient({
275299
</p>
276300
)}
277301

302+
{postSignNotice && (
303+
<p
304+
className={
305+
postSignNotice.tone === "warning"
306+
? "rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-300"
307+
: "rounded-md border border-primary/30 bg-primary/10 px-3 py-2 text-xs text-primary"
308+
}
309+
>
310+
{postSignNotice.message}
311+
</p>
312+
)}
313+
278314
<Button
279315
size="lg"
280316
className="gap-2"
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { start } from "workflow/api"
2+
import { createAuditEvent } from "@/lib/db/queries"
3+
import { type SignClaResult } from "@/lib/cla/signing"
4+
import { runSignerPrSyncWorkflow } from "@/workflows/signer-pr-sync"
5+
6+
export type SignerPrSyncScheduleResult = {
7+
prSyncScheduled: boolean
8+
prSyncRunId: string | null
9+
prSyncScheduleError: string | null
10+
prSyncSkippedReason: string | null
11+
}
12+
13+
export async function scheduleSignerPrSyncAfterSign(params: {
14+
signResult: SignClaResult
15+
actor: {
16+
userId: string
17+
githubId: string | null
18+
githubUsername: string | null
19+
}
20+
}): Promise<SignerPrSyncScheduleResult> {
21+
const { signResult, actor } = params
22+
23+
if (!signResult.org.installationId) {
24+
return {
25+
prSyncScheduled: false,
26+
prSyncRunId: null,
27+
prSyncScheduleError: null,
28+
prSyncSkippedReason: "missing_installation_id",
29+
}
30+
}
31+
32+
try {
33+
const run = await start(runSignerPrSyncWorkflow, [
34+
{
35+
orgSlug: signResult.org.orgSlug,
36+
orgId: signResult.org.id,
37+
signedClaSha256: signResult.org.claSha256,
38+
signer: {
39+
userId: actor.userId,
40+
githubId: actor.githubId,
41+
githubUsername: signResult.signature.githubUsername,
42+
},
43+
repoName: signResult.prSyncContext.repoName,
44+
prNumber: signResult.prSyncContext.prNumber,
45+
},
46+
])
47+
48+
return {
49+
prSyncScheduled: true,
50+
prSyncRunId: run.runId,
51+
prSyncScheduleError: null,
52+
prSyncSkippedReason: null,
53+
}
54+
} catch (error) {
55+
const scheduleError =
56+
error instanceof Error ? error.message : "Unknown signer PR sync scheduling failure"
57+
console.error("Failed to schedule signer PR sync workflow:", error)
58+
59+
try {
60+
await createAuditEvent({
61+
eventType: "signature.pr_sync_schedule_failed",
62+
orgId: signResult.org.id,
63+
userId: actor.userId,
64+
actorGithubId: actor.githubId,
65+
actorGithubUsername: actor.githubUsername,
66+
payload: {
67+
signedClaSha256: signResult.org.claSha256,
68+
repoName: signResult.prSyncContext.repoName,
69+
prNumber: signResult.prSyncContext.prNumber,
70+
error: scheduleError,
71+
},
72+
})
73+
} catch (auditError) {
74+
console.error("Failed to write schedule failure audit event:", auditError)
75+
}
76+
77+
return {
78+
prSyncScheduled: false,
79+
prSyncRunId: null,
80+
prSyncScheduleError: scheduleError,
81+
prSyncSkippedReason: null,
82+
}
83+
}
84+
}

0 commit comments

Comments
 (0)