Skip to content

feat(gastown): configurable merge strategy (direct/PR)#701

Open
jrf0110 wants to merge 1 commit intomainfrom
473-configurable-merge-strategy
Open

feat(gastown): configurable merge strategy (direct/PR)#701
jrf0110 wants to merge 1 commit intomainfrom
473-configurable-merge-strategy

Conversation

@jrf0110
Copy link
Contributor

@jrf0110 jrf0110 commented Mar 1, 2026

Summary

Implements #473 — Configurable Merge Strategy, a sub-issue of #204.

  • Adds a merge_strategy config field (direct | pr) at the town and rig level, with rig-level overrides inheriting from the town default
  • When set to pr, the Refinery creates a GitHub PR or GitLab MR instead of pushing directly to the default branch, enabling human review before agent work lands
  • Polls open PRs on each alarm tick and updates MR bead status when PRs are merged or closed externally
  • PR links are displayed on merge_request beads in the dashboard drawer

Changes by area

Schema & Config

  • MergeStrategy Zod enum and merge_strategy field on TownConfigSchema (defaults to direct)
  • Per-rig merge_strategy override on RigConfig
  • resolveMergeStrategy() helper: rig override → town default → direct
  • Mirrored schema in the Next.js gastown client

PR/MR Creation (platform-pr.util.ts — new)

  • parseGitUrl() — extracts platform/owner/repo from HTTPS and SSH git URLs
  • createGitHubPR() / createGitLabMR() — REST API calls with labels
  • buildPRBody() — Markdown body with quality gate results and agent attribution

Review Queue & Town DO

  • setReviewPrUrl(), markReviewInReview(), listPendingPRReviews() on review queue
  • executeMergeStrategy() dispatches to direct merge or PR creation
  • triggerPRCreation() creates the PR and tracks the URL on the MR bead
  • pollPendingPRs() + checkPRStatus() poll GitHub/GitLab APIs for PR state
  • pr_created and pr_creation_failed bead event types

Refinery Prompt

  • Accepts mergeStrategy param; when pr, instructs the refinery to push the branch and call gt_done (TownDO creates the PR) instead of merging itself

Dashboard UI

  • Merge Strategy radio picker in town settings (direct push vs pull request)
  • PR link badge on merge_request beads in BeadDetailDrawer

Dev Infrastructure

  • Fixed container→host networking: use Docker Desktop host gateway IP (192.168.65.254) instead of host.docker.internal which doesn't work on wrangler's workerd-network
  • Bind wrangler dev to 0.0.0.0 so containers can reach the worker
  • CF Access service token passthrough for production container→worker auth
  • Fail-fast guard when JWT minting fails (prevents launching broken agents)
  • Resolve catalog: references in Dockerfiles for bun compatibility
  • Better error logging in resolveJWTSecret

Tests — 23 unit tests covering merge strategy resolution, git URL parsing, and PR body generation

How to test

  1. Set merge_strategy: "pr" in town settings
  2. Create a bead and let a polecat work on it
  3. When the Refinery runs, it should create a GitHub PR instead of merging directly
  4. The MR bead should show a "View Pull Request" link
  5. Merging/closing the PR externally should update the MR bead status on the next alarm tick

Closes #473

@kilo-code-bot
Copy link
Contributor

kilo-code-bot bot commented Mar 1, 2026

Code Review Summary

Status: 6 Open Issues Remaining | Recommendation: Address before merge

Overview

This PR adds a configurable merge strategy (direct vs pr) to Gastown, migrates the container from bun to pnpm for dependency management, and adds a PR/MR creation flow with status polling. Multiple review rounds have been conducted with 45 inline comments. Most critical and warning issues have been fixed in subsequent commits (f114522, 7245b3d, 406b932, 4c6f1e7, 7eede6a).

Severity Total Found Fixed Remaining
CRITICAL 1 1 0
WARNING 13 9 4
SUGGESTION 8 5 3
Remaining Open Issues (click to expand)

WARNING

File Line Issue Status
cloudflare-gastown/worker-configuration.d.ts N/A Stale CF_ACCESS_AUD value doesn't match wrangler.jsonc Open — generated file may need wrangler types re-run
cloudflare-gastown/src/dos/Town.do.ts 1812 checkPRStatus returns null for unrecognized PR URL formats — MR beads could be stuck forever Open
cloudflare-gastown/src/util/platform-pr.util.ts 221 No special handling for HTTP 422 (duplicate PR) — MR bead marked as failed even though the PR exists Open
cloudflare-gastown/src/types.ts 251 TownConfigUpdateSchema is manually duplicated from TownConfigSchema — no compile-time drift detection Open

SUGGESTION

File Line Issue Status
cloudflare-gastown/src/prompts/refinery-system.prompt.ts N/A Uses inline 'direct' | 'pr' instead of the MergeStrategy type Open
cloudflare-gastown/scripts/prepare-container.mjs 47 Only strips workspace: references from devDependencies, not dependencies Open
cloudflare-gastown/src/dos/town/review-queue.ts 295 Inconsistent timestamp helper — uses new Date().toISOString() instead of the now() helper Open
Fixed Issues (click to expand)

Fixed in f114522

  • CRITICAL: Race condition in recoverStuckReviews — now excludes beads with pr_url set
  • WARNING: as cast in BeadDetailDrawer.tsx — replaced with runtime narrowing
  • WARNING: SSH URLs from unknown hosts defaulting to gitlab — now returns null
  • WARNING: GitLab subgroup parsing broken — rewrote parseGitUrl
  • WARNING: Loose substring hostname match — replaced with exact comparison
  • WARNING: listPendingPRReviews naming confusion — added clarifying comment

Fixed in 7245b3d

  • WARNING: Silent fallback to direct merge when token missing — now marks as failed
  • SUGGESTION: Inline 'direct' | 'pr' type in RigConfig — now uses MergeStrategy
  • WARNING: response.json() inside safeParse — added .catch(() => null) guard

Fixed in 406b932

  • WARNING: Git URL credential leak in bead event metadata — now sanitized
  • SUGGESTION: Empty gateResults in PR body — added explanatory comment
  • WARNING: No rate limiting on PR polling — added MAX_POLLS_PER_TICK = 10
  • SUGGESTION: Inline PR status schemas — extracted to platform-pr.util.ts

Fixed in 4c6f1e7

  • WARNING: parseGitUrl fails on URLs with embedded credentials — now strips them
  • WARNING: resolveJWTSecret falsy return — now explicitly returns null

Fixed in 7eede6a

  • WARNING: GH_TOKEN override order — added comments documenting intentional precedence
Files Reviewed (25 files)
  • cloudflare-gastown/container/.dockerignore — 0 issues
  • cloudflare-gastown/container/.gitignore — 0 issues
  • cloudflare-gastown/container/Dockerfile — 1 issue (fixed)
  • cloudflare-gastown/container/Dockerfile.dev — 0 issues
  • cloudflare-gastown/container/bun.lock — deleted (generated file, skipped)
  • cloudflare-gastown/container/plugin/client.ts — 0 issues
  • cloudflare-gastown/container/src/agent-runner.ts — 1 issue (fixed)
  • cloudflare-gastown/package.json — 0 issues
  • cloudflare-gastown/scripts/prepare-container.mjs — 1 issue (open)
  • cloudflare-gastown/src/db/tables/bead-events.table.ts — 0 issues
  • cloudflare-gastown/src/dos/Town.do.ts — 8 issues (6 fixed, 2 open)
  • cloudflare-gastown/src/dos/town/config.ts — 0 issues
  • cloudflare-gastown/src/dos/town/container-dispatch.ts — 1 issue (fixed)
  • cloudflare-gastown/src/dos/town/review-queue.ts — 3 issues (1 fixed, 2 open)
  • cloudflare-gastown/src/prompts/polecat-system.prompt.ts — 0 issues
  • cloudflare-gastown/src/prompts/refinery-system.prompt.ts — 1 issue (open)
  • cloudflare-gastown/src/types.ts — 1 issue (open)
  • cloudflare-gastown/src/util/platform-pr.util.ts — 5 issues (4 fixed, 1 open)
  • cloudflare-gastown/test/unit/merge-strategy.test.ts — 0 issues
  • cloudflare-gastown/worker-configuration.d.ts — 2 issues (open, generated file)
  • cloudflare-gastown/wrangler.jsonc — 1 issue (open, dev-only)
  • pnpm-lock.yaml — skipped (generated)
  • src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx — 0 issues
  • src/components/gastown/BeadDetailDrawer.tsx — 1 issue (fixed)
  • src/lib/gastown/gastown-client.ts — 0 issues

Fix these issues in Kilo Cloud

@jrf0110 jrf0110 force-pushed the 473-configurable-merge-strategy branch 5 times, most recently from 3846180 to b3274c2 Compare March 2, 2026 03:11
// host.docker.internal → host proxy mapping. Use the Docker
// Desktop VM host gateway IP directly so containers can reach
// the host's dev servers.
"KILO_API_URL": "http://192.168.65.254:3000",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: Hardcoded 192.168.65.254 is macOS Docker Desktop-specific

This IP is the Docker Desktop VM host gateway on macOS. On Linux Docker (where host.docker.internal may not exist either), the host gateway is typically 172.17.0.1 or configured via --add-host. Consider adding a comment noting this is macOS-only, or using an environment variable / .dev.vars override so Linux developers don't have to modify wrangler.jsonc.

@jrf0110 jrf0110 force-pushed the 473-configurable-merge-strategy branch 2 times, most recently from 091068c to b01e42a Compare March 3, 2026 20:12
),
];
if (rows.length === 0) return false;
const row = rows[0] as Record<string, unknown>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: Uses TypeScript as cast, which the project coding style strongly discourages

The as Record<string, unknown> cast bypasses type safety. Since query() returns SqlStorageCursor, you could use a Zod schema to parse the row instead:

const ApprovedRow = z.object({ approved: z.union([z.boolean(), z.number()]).nullable() });
const row = ApprovedRow.parse(rows[0]);
return row.approved === true || row.approved === 1;

Alternatively, if the overhead isn't justified here, at least narrow with a runtime check rather than as.

const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));

// catalog: → concrete versions (keep in sync with root pnpm-workspace.yaml)
const catalog = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: Hardcoded catalog versions must be manually kept in sync with pnpm-workspace.yaml

These version strings are duplicated from the root workspace catalog. If someone updates a catalog version in pnpm-workspace.yaml but forgets to update this file, the container will install a different version than the rest of the monorepo.

Consider either:

  1. Adding a CI check that compares these values against pnpm-workspace.yaml
  2. Generating this map from pnpm-workspace.yaml at build time (e.g. yq or a small Node script that reads the YAML)
  3. At minimum, adding a comment with the path to the source of truth: pnpm-workspace.yamlcatalog: section

# bun install. Versions are defined in resolve-catalog.sh and must be kept
# in sync with the root pnpm-workspace.yaml.
COPY package.json bun.lock resolve-catalog.sh ./
RUN sh resolve-catalog.sh && bun install --production
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: --frozen-lockfile was removed — builds are no longer deterministic

The old bun install --frozen-lockfile --production ensured the lockfile matched package.json exactly. Now that resolve-catalog.sh rewrites package.json at build time, the lockfile won't match and --frozen-lockfile would fail.

This is an intentional tradeoff, but it means bun install can now resolve different versions across builds. Consider regenerating bun.lock after running resolve-catalog.sh and committing that, or documenting this tradeoff in the Dockerfile comment.

@jrf0110 jrf0110 force-pushed the 473-configurable-merge-strategy branch from b01e42a to 8d28245 Compare March 3, 2026 20:29
return 'open';
}

return null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: checkPRStatus silently returns null for unrecognized PR URL formats — MR beads stuck forever

If the prUrl doesn't match the GitHub (/github.com/.../pull/\d+) or GitLab (/.../merge_requests/\d+) regex patterns — e.g. a GitHub Enterprise URL like https://github.mycompany.com/org/repo/pull/42 — this method returns null. The caller (pollPendingPRs) treats null as "skip", so the MR bead stays in in_progress indefinitely with no warning logged.

Consider logging a warning when neither regex matches, so operators can diagnose stuck MR beads:

console.warn(`${TOWN_LOG} checkPRStatus: unrecognized PR URL format: ${prUrl}`);
return null;

// Create a production-only package.json that strips workspace: references
// (they can't be resolved outside the monorepo).
const pkg = JSON.parse(readFileSync(resolve(containerDir, 'package.json'), 'utf8'));
if (pkg.devDependencies) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: Only strips workspace: references from devDependencies, not dependencies

If a production dependency in container/package.json ever uses a workspace: specifier (currently they use catalog: so this is fine today), the generated package.prod.json would still contain the unresolvable reference and pnpm install --prod would fail inside the container.

Consider also stripping workspace: from dependencies for robustness:

for (const section of ['dependencies', 'devDependencies']) {
  if (pkg[section]) {
    for (const [name, version] of Object.entries(pkg[section])) {
      if (typeof version === 'string' && version.startsWith('workspace:')) {
        delete pkg[section][name];
      }
    }
  }
}

@jrf0110 jrf0110 force-pushed the 473-configurable-merge-strategy branch from 8d28245 to 69680fe Compare March 3, 2026 20:40
${beads.columns.updated_at} = ?
WHERE ${beads.bead_id} = ?
`,
[new Date().toISOString(), entryId]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: Inconsistent timestamp helper — uses new Date().toISOString() instead of the now() helper

Every other function in this file uses the local now() helper (defined at line 27) for consistency. This one-off new Date().toISOString() call does the same thing but breaks the pattern, making it easy to miss if now() is ever changed (e.g. to use a monotonic clock or add logging).

Suggested change
[new Date().toISOString(), entryId]
[now(), entryId]

}
);

if (!response.ok) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: No special handling for HTTP 422 (duplicate PR) — MR bead marked as failed even though the PR exists

If a PR already exists for the same head→base pair, GitHub returns 422 with "A pull request already exists". This can happen if the alarm retries after a transient network failure where the PR was created but the response was lost.

The current code throws a generic error, which causes triggerPRCreation to mark the MR bead as failed — even though the PR actually exists on GitHub. The bead is then stuck in failed state with no pr_url, so pollPendingPRs never picks it up.

Consider detecting 422 and attempting to find the existing PR via the GitHub search/list API, or at minimum logging a distinct error so operators can manually recover.

* .default() during parsing, injecting phantom values (e.g. merge_strategy:
* 'direct') that overwrite existing config on partial updates.
*/
export const TownConfigUpdateSchema = z.object({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: TownConfigUpdateSchema is manually duplicated from TownConfigSchema — no compile-time drift detection

The comment at line 246 explains why .partial() can't be used (Zod fires .default() during parsing). However, this manual duplication means any new field added to TownConfigSchema must also be manually added here. There's no compile-time check to catch drift.

Consider adding a type-level assertion to catch missing fields, e.g.:

type _AssertUpdateCoversConfig = keyof TownConfig extends keyof TownConfigUpdate ? true : never;
const _: _AssertUpdateCoversConfig = true;

This would cause a compile error if TownConfigSchema gains a field that TownConfigUpdateSchema doesn't have.

Add a merge_strategy config field (direct | pr) at the town and rig
level. When set to 'pr', the Refinery creates a GitHub PR or GitLab MR
instead of pushing directly to the default branch, enabling human
review of agent work before it lands.

Schema & Config:
- MergeStrategy enum and merge_strategy field on TownConfigSchema
- Per-rig override on RigConfig with town-level inheritance
- Mirrored schema in Next.js gastown client

PR/MR Creation (platform-pr.util.ts):
- parseGitUrl with GitLab subgroup support and credential stripping
- createGitHubPR / createGitLabMR via REST APIs
- buildPRBody with quality gate results and agent attribution
- GitHubPRStatusSchema / GitLabMRStatusSchema for polling

Review Queue & Town DO:
- setReviewPrUrl, markReviewInReview, listPendingPRReviews
- executeMergeStrategy dispatches to direct merge or PR creation
- triggerPRCreation with fail-fast on missing tokens (no silent
  fallback to direct merge)
- pollPendingPRs with rate limiting (10 PRs/tick)
- recoverStuckReviews excludes PR-strategy beads
- Sanitize git URLs in logs and bead event metadata

Refinery & Polecat Prompts:
- Refinery prompt accepts mergeStrategy param
- Polecat prompt explicitly prohibits PR/MR creation

Dashboard UI:
- Merge strategy radio picker in town settings
- PR link on merge_request beads in BeadDetailDrawer

Dev Infrastructure:
- Fix container networking: use Docker Desktop host gateway IP
- Bind wrangler dev to 0.0.0.0
- Resolve catalog: references in Dockerfiles for bun compatibility
- Authenticate gh CLI via GH_TOKEN from git credentials
- Fail fast when JWT minting fails
- Return null for falsy JWT secrets

30 unit tests covering merge strategy resolution, git URL parsing
(including subgroups, SSH, credential-bearing URLs), and PR body
generation.
@jrf0110 jrf0110 force-pushed the 473-configurable-merge-strategy branch from 69680fe to 7eede6a Compare March 3, 2026 20:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Configurable merge strategy: direct push vs pull request

1 participant