From 9f5fdb4bd5d70f238dae0283c8ad24e48ce2113d Mon Sep 17 00:00:00 2001 From: Kyle Adams Date: Tue, 30 Jun 2026 14:30:13 -0400 Subject: [PATCH] feat(github): support user-defined PR labels --- .../src/db/integration-settings.ts | 8 ++++ .../src/routes/integration-settings.ts | 1 + .../src/session/durable-object.ts | 16 ++++++++ .../src/session/pull-request-service.ts | 3 ++ .../providers/github-provider.ts | 41 +++++++++++++++++++ packages/shared/src/types/integrations.ts | 2 + .../github-integration-settings.tsx | 26 ++++++++++++ 7 files changed, 97 insertions(+) diff --git a/packages/control-plane/src/db/integration-settings.ts b/packages/control-plane/src/db/integration-settings.ts index ce9912081..7c381c683 100644 --- a/packages/control-plane/src/db/integration-settings.ts +++ b/packages/control-plane/src/db/integration-settings.ts @@ -280,6 +280,14 @@ export class IntegrationSettingsStore { throw new IntegrationSettingsValidationError("commentActionInstructions must be a string"); } + if (settings.prLabel !== undefined && typeof settings.prLabel !== "string") { + throw new IntegrationSettingsValidationError("prLabel must be a string"); + } + + if (settings.prLabel !== undefined) { + settings = { ...settings, prLabel: settings.prLabel.trim() || undefined }; + } + if (settings.allowedTriggerUsers !== undefined) { if ( !Array.isArray(settings.allowedTriggerUsers) || diff --git a/packages/control-plane/src/routes/integration-settings.ts b/packages/control-plane/src/routes/integration-settings.ts index 73bee0f16..88ba610e8 100644 --- a/packages/control-plane/src/routes/integration-settings.ts +++ b/packages/control-plane/src/routes/integration-settings.ts @@ -314,6 +314,7 @@ async function handleGetResolvedConfig( allowedTriggerUsers: githubSettings.allowedTriggerUsers ?? null, codeReviewInstructions: githubSettings.codeReviewInstructions ?? null, commentActionInstructions: githubSettings.commentActionInstructions ?? null, + prLabel: githubSettings.prLabel ?? null, }, }); } diff --git a/packages/control-plane/src/session/durable-object.ts b/packages/control-plane/src/session/durable-object.ts index b5f0bc7b7..175fc27bd 100644 --- a/packages/control-plane/src/session/durable-object.ts +++ b/packages/control-plane/src/session/durable-object.ts @@ -480,6 +480,21 @@ export class SessionDO extends DurableObject { return webAppUrl + "/session/" + sessionId; }, createPullRequest: async (input) => { + let prLabel: string | undefined; + const session = this.getSession(); + if (session && this.env.DB) { + try { + const settingsStore = new IntegrationSettingsStore(this.env.DB); + const { settings } = await settingsStore.getResolvedConfig( + "github", + `${session.repo_owner}/${session.repo_name}` + ); + prLabel = settings.prLabel || undefined; + } catch { + // Best-effort — don't fail PR creation if settings read fails + } + } + const pullRequestService = new SessionPullRequestService({ repository: this.repository, sourceControlProvider: this.sourceControlProvider, @@ -500,6 +515,7 @@ export class SessionDO extends DurableObject { }); }, appName: resolveAppName(this.env), + prLabel, }); return pullRequestService.createPullRequest(input); diff --git a/packages/control-plane/src/session/pull-request-service.ts b/packages/control-plane/src/session/pull-request-service.ts index 84d32399d..9a1a95a07 100644 --- a/packages/control-plane/src/session/pull-request-service.ts +++ b/packages/control-plane/src/session/pull-request-service.ts @@ -63,6 +63,8 @@ export interface PullRequestServiceDeps { broadcastArtifactCreated: (artifact: SessionArtifact) => void; /** Display name used in the PR body footer (e.g. "Created with [name](url)"). */ appName: string; + /** Label applied to agent-created PRs. Created in the target repo if absent. */ + prLabel?: string; } /** @@ -197,6 +199,7 @@ export class SessionPullRequestService { body: fullBody, sourceBranch: sanitizedHeadBranch, targetBranch: baseBranch, + labels: this.deps.prLabel ? [this.deps.prLabel] : undefined, }); const artifactId = this.deps.generateId(); diff --git a/packages/control-plane/src/source-control/providers/github-provider.ts b/packages/control-plane/src/source-control/providers/github-provider.ts index dbd1ea961..11ca27d27 100644 --- a/packages/control-plane/src/source-control/providers/github-provider.ts +++ b/packages/control-plane/src/source-control/providers/github-provider.ts @@ -181,6 +181,12 @@ export class GitHubSourceControlProvider implements SourceControlProvider { // Add labels if requested if (config.labels && config.labels.length > 0) { + await this.ensureLabels( + auth.token, + config.repository.owner, + config.repository.name, + config.labels + ); await this.addLabels( auth.token, config.repository.owner, @@ -372,6 +378,41 @@ export class GitHubSourceControlProvider implements SourceControlProvider { }; } + /** + * Ensure labels exist in a repository, creating any that are missing. + * This is a best-effort operation - failures are logged but don't fail the PR creation. + */ + private async ensureLabels( + accessToken: string, + owner: string, + repo: string, + labels: string[] + ): Promise { + for (const label of labels) { + try { + const response = await fetchWithTimeout( + `${GITHUB_API_BASE}/repos/${owner}/${repo}/labels`, + { + method: "POST", + headers: { + Accept: "application/vnd.github.v3+json", + Authorization: `Bearer ${accessToken}`, + "User-Agent": this.userAgent, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: label, color: "ededed" }), + } + ); + // 201 = created, 422 = already exists — both are fine + if (!response.ok && response.status !== 422) { + console.warn(`Failed to ensure label "${label}" in ${owner}/${repo}: ${response.status}`); + } + } catch (error) { + console.warn(`Failed to ensure label "${label}" in ${owner}/${repo}:`, error); + } + } + } + /** * Add labels to a pull request. * This is a best-effort operation - failures are logged but don't fail the PR creation. diff --git a/packages/shared/src/types/integrations.ts b/packages/shared/src/types/integrations.ts index cda086ac3..b4b400bc0 100644 --- a/packages/shared/src/types/integrations.ts +++ b/packages/shared/src/types/integrations.ts @@ -24,6 +24,8 @@ export interface GitHubBotSettings { allowedTriggerUsers?: string[]; codeReviewInstructions?: string; commentActionInstructions?: string; + /** Label applied to every agent-created PR. The label is created in the target repo if it doesn't exist. */ + prLabel?: string; } /** Overridable behavior settings for the Linear bot. Used at both global (defaults) and per-repo (overrides) levels. */ diff --git a/packages/web/src/components/settings/integrations/github-integration-settings.tsx b/packages/web/src/components/settings/integrations/github-integration-settings.tsx index 161a9185b..9eb470b39 100644 --- a/packages/web/src/components/settings/integrations/github-integration-settings.tsx +++ b/packages/web/src/components/settings/integrations/github-integration-settings.tsx @@ -142,6 +142,7 @@ function GlobalSettingsSection({ const [commentActionInstructions, setCommentActionInstructions] = useState( settings?.defaults?.commentActionInstructions ?? "" ); + const [prLabel, setPrLabel] = useState(settings?.defaults?.prLabel ?? ""); const [newUsername, setNewUsername] = useState(""); const [saving, setSaving] = useState(false); const [error, setError] = useState(""); @@ -161,6 +162,7 @@ function GlobalSettingsSection({ ); setCodeReviewInstructions(settings.defaults?.codeReviewInstructions ?? ""); setCommentActionInstructions(settings.defaults?.commentActionInstructions ?? ""); + setPrLabel(settings.defaults?.prLabel ?? ""); } setInitialized(true); } @@ -188,6 +190,7 @@ function GlobalSettingsSection({ setTriggerUserMode("write_access"); setCodeReviewInstructions(""); setCommentActionInstructions(""); + setPrLabel(""); setNewUsername(""); setDirty(false); toast.success("Settings reset to defaults."); @@ -205,6 +208,7 @@ function GlobalSettingsSection({ const handleSave = async () => { setSaving(true); setError(""); + const normalizedPrLabel = prLabel.trim(); const body: GitHubGlobalConfig = { defaults: { @@ -212,6 +216,7 @@ function GlobalSettingsSection({ ...(triggerUserMode === "specific" ? { allowedTriggerUsers } : {}), ...(codeReviewInstructions ? { codeReviewInstructions } : {}), ...(commentActionInstructions ? { commentActionInstructions } : {}), + ...(normalizedPrLabel ? { prLabel: normalizedPrLabel } : {}), }, }; @@ -228,6 +233,7 @@ function GlobalSettingsSection({ if (res.ok) { mutate(GLOBAL_SETTINGS_KEY); + setPrLabel(normalizedPrLabel); toast.success("Settings saved."); setDirty(false); } else { @@ -473,6 +479,26 @@ function GlobalSettingsSection({ /> +
+ +

+ Label applied to every agent-created pull request. The label is created in the target + repository if it does not already exist. Leave blank to apply no label. +

+ { + setPrLabel(e.target.value); + setDirty(true); + setError(""); + }} + placeholder="e.g., contractor" + /> +
+