Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/react-doctor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
# Full history so React Doctor can diff against the merge base and
# report only issues this PR introduces (not pre-existing ones).
fetch-depth: 0

- uses: millionco/react-doctor@v2
# Advisory by default: React Doctor reports findings on every PR — a
Expand Down
139 changes: 134 additions & 5 deletions apps/web/src/integrations/trpc/routers/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,20 @@ import {
contributorRequests,
repositories,
whitelistEntries,
type AppealContentType,
type RequestKind,
} from "@tripwire/db"
import { logEvent } from "@tripwire/core"
import {
loadPrefsForInstallation,
logEvent,
renderDecisionComment,
} from "@tripwire/core"
import {
addComment,
getInstallationToken,
reopenIssue,
reopenPullRequest,
} from "@tripwire/github"

import type { TRPCRouterRecord } from "@trpc/server"

Expand Down Expand Up @@ -81,6 +92,8 @@ export const requestsRouter = {
repoFullName: z.string().min(3),
kind: kindEnum,
reason: z.string().min(10).max(2000),
ref: z.number().int().positive().optional(),
contentType: z.enum(["pull_request", "issue"]).optional(),
})
)
.mutation(async ({ input, ctx }) => {
Expand Down Expand Up @@ -148,6 +161,13 @@ export const requestsRouter = {
githubUserId: ghUser.id,
avatarUrl: ghUser.avatar_url,
reason: input.reason,
// Only unblock appeals target a specific PR/issue to reopen.
githubRef:
input.kind === "unblock" && input.ref ? `#${input.ref}` : undefined,
contentType:
input.kind === "unblock"
? (input.contentType ?? undefined)
: undefined,
})
.returning()

Expand Down Expand Up @@ -194,10 +214,11 @@ export const requestsRouter = {
})
)
.mutation(async ({ input, ctx }) => {
const { request: req } = await assertRequestBelongsToOrg(
input.requestId,
ctx.activeOrgId
)
const {
request: req,
repo,
org,
} = await assertRequestBelongsToOrg(input.requestId, ctx.activeOrgId)

const nextStatus = input.decision === "approve" ? "approved" : "denied"

Expand Down Expand Up @@ -259,10 +280,118 @@ export const requestsRouter = {
},
})

// Reopen the appealed PR/issue (on approve) and notify the contributor of
// the outcome via a GitHub comment. Best-effort and outside the DB
// transaction: the decision is already committed, so a GitHub hiccup must
// not fail the mutation.
await notifyDecisionOnGithub(
req,
repo.fullName,
org.githubInstallationId,
input.decision
).catch((err) =>
console.error(
`[requests] Failed to notify/reopen for request ${req.id}:`,
err
)
)

return { status: nextStatus }
}),
} satisfies TRPCRouterRecord

interface DecidedRequest {
id: string
repoId: string
kind: RequestKind
githubUsername: string
githubUserId: number | null
githubRef: string | null
contentType: AppealContentType | null
}

async function notifyDecisionOnGithub(
req: DecidedRequest,
repoFullName: string,
installationId: number,
decision: "approve" | "deny"
) {
// Only unblock appeals are tied to a reopenable PR/issue. Legacy links and
// access requests carry no ref, so there's nothing to reopen or comment on.
if (req.kind !== "unblock" || !req.githubRef || !req.contentType) return

const [owner, repo] = repoFullName.split("/")
const number = Number(req.githubRef.replace(/^#/, ""))
if (!owner || !repo || !Number.isFinite(number)) return

const kind = req.contentType
const token = await getInstallationToken(installationId)
const prefs = await loadPrefsForInstallation(installationId)

if (decision === "deny") {
await addComment(
token,
owner,
repo,
number,
renderDecisionComment({
prefs,
decision,
username: req.githubUsername,
kind,
})
)
return
}

// Approve: reopen the content, then notify. If the head branch was deleted
// GitHub rejects the reopen — still notify, but don't claim it reopened.
let reopened = false
try {
if (kind === "pull_request") {
await reopenPullRequest(token, owner, repo, number)
} else {
await reopenIssue(token, owner, repo, number)
}
reopened = true
} catch (err) {
console.error(`[requests] Failed to reopen ${req.githubRef}:`, err)
}

// Log the reopen before the best-effort comment so a comment failure
// (network, rate limit) can't drop the audit trail for a reopen that
// actually happened on GitHub.
if (reopened) {
await logEvent({
repoId: req.repoId,
action:
kind === "pull_request"
? "github_pr_reopened"
: "github_issue_reopened",
severity: "success",
contentType: kind,
description: `Reopened ${kind === "pull_request" ? "PR" : "issue"} ${req.githubRef} after approving @${req.githubUsername}'s appeal`,
targetGithubUsername: req.githubUsername,
targetGithubUserId: req.githubUserId ?? undefined,
githubRef: req.githubRef,
})
}

await addComment(
token,
owner,
repo,
number,
renderDecisionComment({
prefs,
decision,
username: req.githubUsername,
kind,
reopened,
})
)
}

type DbOrTx = Parameters<Parameters<typeof db.transaction>[0]>[0] | typeof db

async function applyApproval(
Expand Down
35 changes: 26 additions & 9 deletions apps/web/src/routes/request.$owner.$repo.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { useState } from "react"
import { createFileRoute } from "@tanstack/react-router"
import { useMutation, useQuery } from "@tanstack/react-query"
import { parseAsString, parseAsStringEnum, useQueryStates } from "nuqs"
import {
parseAsInteger,
parseAsString,
parseAsStringEnum,
useQueryStates,
} from "nuqs"
import { authClient } from "@tripwire/auth/client"
import { useTRPC } from "#/integrations/trpc/react"
import { Button } from "@tripwire/ui/button"
Expand All @@ -24,11 +29,15 @@ export const Route = createFileRoute("/request/$owner/$repo")({

function RequestPage() {
const { owner, repo } = Route.useParams()
const [{ kind, u: intendedUser }, setSearch] = useQueryStates({
const [{ kind, u: intendedUser, ref, ct }, setSearch] = useQueryStates({
kind: parseAsStringEnum(["unblock", "access"] as const).withDefault(
"unblock"
),
u: parseAsString,
// The closed PR/issue this appeal is for, carried from the block comment's
// appeal link so an approval can reopen the exact content.
ref: parseAsInteger,
ct: parseAsStringEnum(["pull_request", "issue"] as const),
})
const trpc = useTRPC()
const { data: session, isPending } = authClient.useSession()
Expand All @@ -38,14 +47,14 @@ function RequestPage() {
const [reason, setReason] = useState("")
const [submitted, setSubmitted] = useState(false)

const whoamiQuery = useQuery({
const { data: whoami } = useQuery({
...trpc.requests.whoami.queryOptions(),
enabled: !!session,
staleTime: 60 * 1000,
})
const currentGhLogin = whoamiQuery.data?.githubLogin ?? null
const currentGhLogin = whoami?.githubLogin ?? null

const vouchQuery = useQuery({
const { data: vouch } = useQuery({
...trpc.vouches.check.queryOptions({ username: currentGhLogin ?? "" }),
enabled: !!currentGhLogin,
staleTime: 60 * 1000,
Expand Down Expand Up @@ -80,7 +89,15 @@ function RequestPage() {

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
submit.mutate({ repoFullName, kind, reason })
submit.mutate({
repoFullName,
kind,
reason,
// Only send the ref for unblock appeals — access requests aren't tied to
// a specific PR/issue.
ref: kind === "unblock" && ref ? ref : undefined,
contentType: kind === "unblock" && ct ? ct : undefined,
})
}

const canSubmit = reason.trim().length >= 10 && !submit.isPending
Expand Down Expand Up @@ -167,14 +184,14 @@ function RequestPage() {
</div>
) : (
<>
{vouchQuery.data?.isVouched && (
{vouch?.isVouched && (
<div className="flex items-center gap-3">
<div className="text-[13px] text-tw-text-secondary">
<span className="font-medium text-tw-text-primary">
You&apos;re vouched.
</span>{" "}
You have {vouchQuery.data.vouchCount} vouch
{vouchQuery.data.vouchCount !== 1 ? "es" : ""} from
You have {vouch.vouchCount} vouch
{vouch.vouchCount !== 1 ? "es" : ""} from
Tripwire maintainers. Some repositories may auto-approve
your contributions.
</div>
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/filter-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1443,6 +1443,7 @@ export async function handlePullRequest(
username: ctx.senderLogin,
outcome: result.outcome,
kind: "pull_request",
contentNumber: prNumber,
appBaseUrl: APP_BASE_URL,
})
await closePullRequest(token, owner, repo, prNumber, comment)
Expand Down Expand Up @@ -1537,6 +1538,7 @@ export async function handleIssue(
username: ctx.senderLogin,
outcome: result.outcome,
kind: "issue",
contentNumber: issueNumber,
appBaseUrl: APP_BASE_URL,
})
await closeIssue(token, owner, repo, issueNumber, comment)
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export * from "./contributor-score"
export * from "./contributor-fetch"
export * from "./contributor-identity"
export * from "./filter-pipeline"
export * from "./pr-comment"
export * from "./pr-comment-loader"
export * from "./language-detection"
export * from "./rules/config-schema"
export * from "./rules/config-draft"
Expand Down
Loading
Loading