Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0bde81f
feat: harden native GitHub state reconciliation
Codename-11 Jul 15, 2026
37c0ebf
fix: preserve GitHub lifecycle reconciliation
Codename-11 Jul 15, 2026
f349a33
fix: preserve links during GitHub repo canonicalization
Codename-11 Jul 15, 2026
f02ee8e
fix: enforce GitHub freshness across legacy links
Codename-11 Jul 15, 2026
6e4e731
fix: canonicalize GitHub review webhook identity
Codename-11 Jul 15, 2026
0f43763
fix: preserve same-second GitHub reopen events
Codename-11 Jul 15, 2026
dad883b
Merge remote-tracking branch 'origin/main' into codex/github-state-ha…
Codename-11 Jul 15, 2026
8b7f58e
fix: preserve decisive GitHub review state
Codename-11 Jul 15, 2026
53ecd8b
fix: aggregate GitHub review and check hints safely
Codename-11 Jul 15, 2026
96478ac
fix: preserve review aggregate on request events
Codename-11 Jul 15, 2026
bc6801a
fix: publish GitHub review invalidation activity
Codename-11 Jul 15, 2026
79a903b
fix: order GitHub review hints by event time
Codename-11 Jul 15, 2026
aae449f
fix: prioritize pending GitHub review requests
Codename-11 Jul 15, 2026
d6597b9
fix: isolate GitHub issue comment freshness
Codename-11 Jul 15, 2026
6798bb0
fix: reset stale GitHub check rerun state
Codename-11 Jul 15, 2026
7a9fce2
fix: preserve merged state on stale reopen
Codename-11 Jul 15, 2026
aed127a
fix: ignore partial GitHub review watermarks
Codename-11 Jul 15, 2026
b21538a
fix: guard GitHub webhook lease completion
Codename-11 Jul 15, 2026
d39185c
fix: keep in-flight GitHub deliveries retryable
Codename-11 Jul 15, 2026
dbc66a3
fix: prefer provider-versioned GitHub resources
Codename-11 Jul 15, 2026
8d5c11f
fix: isolate GitHub review lifecycle freshness
Codename-11 Jul 15, 2026
4dd1033
fix: make GitHub link recovery range-safe
Codename-11 Jul 15, 2026
406d170
fix: accept GitHub review dismissals safely
Codename-11 Jul 15, 2026
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ The dashboard's **What's New** rail reads this file at request time
entries. Keep entries terse — one line per item under each version
date, grouped by `Added` / `Changed` / `Fixed` / `Removed`.

## [2026-07-15] — v0.18.0 · Reliable GitHub state detection

### Added

- **Pull-request review state is aggregated from GitHub.** Forge records the latest decisive review per reviewer together with outstanding reviewer and team requests, without copying PR discussion into issue comments.
- **Legacy GitHub link attachments recover into native relations.** A bounded worker repair promotes old issue/PR links when their native resource already exists, restoring normal state sync without provider calls.

### Fixed

- **Webhook delivery is retryable and ordered.** Failed or abandoned deliveries use atomic processing leases, concurrent and out-of-order updates cannot regress newer PR state, and repository mappings must match both the requested repo and installation.
- **Checks and commit statuses invalidate immediately.** Queued/rerun checks, SHA-only check events, and legacy `status` webhooks mark aggregate evidence dirty; provider refresh remains the only source trusted for safe automatic completion.
- **Linked GitHub changes reach live issue views.** Meaningful checks, review, draft, mergeability, head-SHA, title, and lifecycle changes publish issue-scoped activity while duplicate links and mirrored GitHub issue comments remain idempotent.
- **Native GitHub installation cannot borrow a runtime-auth app.** Issue/PR sync now requires the configured instance GitHub App with the documented read permissions and active webhook events.

## [2026-07-15] — v0.17.0 · Clear configuration ownership

### Added
Expand Down
81 changes: 81 additions & 0 deletions DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ fresh production build passed; and the full serial Playwright suite passed
hard-coded two rail columns despite the existing work-count threshold; the
assertion now follows the rendered layout mode and passed both alone and in the
full suite.

## 2026-07-14 — Settings scope and information-architecture review

Mapped Forge's personal/global, workspace, Mission Control/Activity, and
Expand Down Expand Up @@ -12966,3 +12967,83 @@ repository warnings; the focused comment router suite passed 15 tests; the
isolated stale-work suite passed 9 tests after a known full-suite concurrency
failure; the fresh E2E production build passed; and the existing issue-flow
Playwright journey passed.

---

## 2026-07-14 — Native GitHub state hardening

Audited native link creation, webhook ingestion, provider refresh, stale
reconciliation, checks/status aggregation, completion/recovery policy, and the
GitHub-discussion boundary. Closed repo/installation mismatches, casing-based
resource duplication, permanent failed-delivery dedupe, concurrent and
out-of-order regressions, same-SHA rerun gaps, missing legacy status-webhook
invalidation, comment duplication, and missing issue-scoped realtime events.

Pull refreshes now aggregate the latest decisive review per reviewer and
outstanding review requests while leaving PR comments and review bodies on
GitHub. The worker promotes recoverable legacy generic GitHub attachments into
native relations without provider calls. Native sync no longer substitutes a
separate runtime-auth GitHub App, and the documented instance-app permissions
now include checks, commit statuses, and the `status` webhook event.

PR review caught twenty-six identity/bounded-prefix/race edge cases before merge. Lifecycle
rules now compare the provider's lifecycle version rather than volatile check
metadata, so a concurrent check hint cannot suppress a merged/closed action.
Legacy-link recovery selects only attachments with an existing matching native
resource and a live target issue, so permanently unmatched or soft-deleted
links cannot starve or abort recoverable rows.
Canonical repo casing now rekeys the existing resource and preserves or merges
its issue relations instead of creating a new unlinked canonical row, while
case-insensitive lifecycle and review-webhook lookups still reject delayed
provider state without regressing a newer terminal resource. Equal-second
terminal tie-breaks now admit only an explicit `reopened` action, so a genuine
reopen is not discarded while ambiguous open/synchronize deliveries remain
conservative. Individual review events never replace the provider aggregate;
they preserve the latest decisive review state and mark it dirty for provider
refresh. Reviewer-requested/removed events use a separate dirty hint so they
cannot clear that aggregate, and review dirty/hint changes participate in the
issue activity fingerprint so open views refresh immediately. Review-event
ordering compares both provider-aggregate and prior webhook timestamps so a
late changes-requested event cannot fire after a newer approval hint. SHA-only
check/status events now filter by the JSON head SHA in the database instead of
loading every PR for a repository into the worker. Provider review aggregation
prioritizes changes requested, then outstanding reviewer/team requests, then
approvals. Mirrored issue comments no longer advance the native resource's
lifecycle freshness clock, so a newer comment delivery cannot suppress later
issue opened/closed/reopened side effects.
Rerequested/requested check webhooks now ignore the prior run's stale
conclusion and only dirty the aggregate for reconciliation, preventing a rerun
request from firing the checks-failed status rule. GitHub App setup now calls
out the write-level Checks permission required for rerun webhook actions. The
same-second reopen exception is limited to closed/unmerged PRs, so a stale
reopen can never regress an already merged resource.
Partial/failed review reads no longer contribute an ordering watermark, so a
delayed decisive review webhook can recover the only available review signal
and invalidate the aggregate normally. Review ordering now also rejects stale
non-decisive events so a late comment cannot rewind the decisive-event
watermark. Webhook completion/failure updates are conditional on the exact
processing lease, preventing an expired handler from overwriting a reclaimed
attempt's result. A redelivery that encounters a live `RECEIVED` lease now
returns a retryable failure rather than a completed-duplicate acknowledgement;
only terminal delivery rows are acknowledged as durable duplicates.
Mixed-case duplicate canonicalization now ranks rows with a provider
`externalUpdatedAt` above internally touched/unversioned rows before comparing
recency, preserving the authoritative lifecycle snapshot and all moved links.
Review-first PR deliveries now seed identity/review hints without advancing the
PR lifecycle freshness clock, so delayed opened/synchronize webhooks can still
link issue keys and apply configured lifecycle rules.
Legacy attachment recovery compares captured GitHub numbers as normalized text
rather than casting untrusted URL digits to `int4`, so an oversized malformed
link cannot abort a reconciliation sweep or starve valid recovery candidates.
Review-dismissed webhooks bypass submission-time staleness because GitHub
retains the original review timestamp; they dirty the provider aggregate while
preserving any newer webhook watermark instead of rewinding it.

Verification: the focused GitHub/client/reconciliation/completion suites passed
56 tests; lint passed with existing repository warnings; typecheck passed; and
the CI-style serial Vitest gate passed 1,274 tests with one intentional live
connector skip. The canonical parallel gate also passed 1,274 tests and the
fresh production E2E build completed. The browser run passed 35 of 38 journeys
before Chromium crashed on a memory-saturated host; each of the three reported
journeys passed immediately in a fresh isolated browser process. Release,
deployment, and production smoke results follow after the pull request lands.
10 changes: 7 additions & 3 deletions docs/reference/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,13 @@ Configure the GitHub App with:
- Setup URL: `https://forge.example/api/connections/github/setup`
- Webhook URL: `https://forge.example/api/ingest/github`
- Webhook events: `issues`, `issue_comment`, `pull_request`,
`pull_request_review`, `check_suite`, `check_run`
- Read permissions for issues and pull requests. Repository metadata access is
needed for repository selection/listing.
`pull_request_review`, `check_suite`, `check_run`, `status`
- Read permissions for issues, pull requests, and commit statuses.
- **Read and write** permission for checks. GitHub requires write-level Checks
permission to deliver the `requested`/`rerequested` check-suite and check-run
actions Forge uses to invalidate a cached CI aggregate when a rerun starts.
Forge otherwise reads check data and does not create check runs.
- Repository metadata access for repository selection/listing.

## Storage (MinIO / S3)

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "forge",
"version": "0.17.0",
"version": "0.18.0",
"private": true,
"description": "Forge — a fast, minimalist, keyboard-driven project management platform with pluggable agents.",
"license": "MIT",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- Retry-safe processing leases for native GitHub webhook deliveries.
-- Existing terminal rows keep their historical attempt count; RECEIVED rows
-- use receivedAt as their initial lease start so a crashed request can be
-- reclaimed after the application-level stale threshold.

ALTER TABLE "ExternalWebhookEvent"
ADD COLUMN "processingStartedAt" TIMESTAMP(3),
ADD COLUMN "attemptCount" INTEGER NOT NULL DEFAULT 1;

UPDATE "ExternalWebhookEvent"
SET "processingStartedAt" = "receivedAt"
WHERE "processingStartedAt" IS NULL;
5 changes: 5 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -2267,6 +2267,11 @@ model ExternalWebhookEvent {
status String
error String? @db.Text
receivedAt DateTime @default(now())
/// Start of the current processing lease. Failed or abandoned deliveries
/// can be atomically reclaimed without racing an in-flight request.
processingStartedAt DateTime?
/// Number of processing claims, including the initial delivery.
attemptCount Int @default(1)
processedAt DateTime?

workspace Workspace? @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
Expand Down
34 changes: 5 additions & 29 deletions src/app/api/connections/github/install/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import "server-only";
import { createHmac, randomBytes } from "node:crypto";
import { NextResponse, type NextRequest } from "next/server";
import { auth } from "@/server/auth";
import { db } from "@/server/db";
import { publicOrigin } from "@/server/integrations/public-origin";

const COOKIE = "forge_github_app_install";
Expand Down Expand Up @@ -31,44 +30,21 @@ function signState(state: string, returnTo: string): string {
return `${state}.${encodedReturnTo}.${sig}`;
}

/**
* Resolve the GitHub App slug to deep-link the install flow at. Prefer the
* `GITHUB_APP_SLUG` env, but fall back to a GitHub App the user already
* configured (e.g. via Settings → GitHub Apps "Create with GitHub") so a
* single-tenant deploy that never set the env var still works. Scopes to the
* caller's workspaces; prefers the workspace in `returnTo` when present.
*/
async function resolveAppSlug(userId: string, returnTo: string): Promise<string | null> {
const fromEnv = process.env.GITHUB_APP_SLUG?.trim();
if (fromEnv) return fromEnv;

const wsSlug = returnTo.match(/^\/w\/([^/]+)(?:\/|$)/)?.[1];
const memberOf = { memberships: { some: { userId } } };
const pick = async (workspaceWhere: Record<string, unknown>) =>
db.githubApp.findFirst({
where: { slug: { not: null }, workspace: workspaceWhere },
orderBy: [{ lastMintedAt: "desc" }, { createdAt: "desc" }],
select: { slug: true },
});

// Prefer the workspace the operator came from, then any workspace they belong to.
const scoped = wsSlug ? await pick({ ...memberOf, slug: wsSlug }) : null;
const app = scoped ?? (await pick(memberOf));
return app?.slug ?? null;
}

export async function GET(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.redirect(new URL("/signin", publicOrigin(req)));
}

const returnTo = safeReturnTo(req);
const appSlug = await resolveAppSlug(session.user.id, returnTo);
// Native issue/PR sync uses the instance GitHub App credentials. Workspace
// runtime-auth apps deliberately have different permissions, keys, and an
// inactive webhook, so their slug must never be substituted here.
const appSlug = process.env.GITHUB_APP_SLUG?.trim() || null;
if (!appSlug) {
return settingsRedirect(
req,
"No GitHub App is configured. Create one in Settings → GitHub Apps (Create with GitHub), or set GITHUB_APP_SLUG.",
"Native GitHub sync is not configured. Set GITHUB_APP_SLUG and the matching instance GitHub App credentials.",
);
}

Expand Down
Loading
Loading