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
1 change: 0 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,6 @@ LOOPOVER_REVIEW_DRAFT=false
# DRAFT_TOKEN_ENCRYPTION_SECRET= # AES-256-GCM secret for the contributor OAuth token (draft flow)
# LOOPOVER_REVIEW_STATS_TOKEN= # bearer token guarding the stats data endpoint
# LOOPOVER_DRIFT_ISSUE_TOKEN= # token for auto-filing drift issues
# LOOPOVER_CONTRIBUTOR_ISSUE_TOKEN= # token for contributor-issue automation
# PRODUCT_USAGE_HASH_SALT= # salt for hashing product-usage identifiers

# =============================================================================
Expand Down
1 change: 0 additions & 1 deletion src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,6 @@ declare global {
SENTRY_MIN_SEVERITY?: string;
/** Per-repo override map for SENTRY_MIN_SEVERITY — see its doc comment for the shape and precedence. */
SENTRY_REPO_MIN_SEVERITY?: string;
LOOPOVER_CONTRIBUTOR_ISSUE_TOKEN?: string;
PRODUCT_USAGE_HASH_SALT?: string;
/** Server-to-server API bearer token — bypasses per-repo write checks (src/auth/security.ts). */
LOOPOVER_API_TOKEN?: string;
Expand Down
60 changes: 60 additions & 0 deletions src/github/issues.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { withInstallationTokenRetry } from "./app";
import { githubRateLimitAdmissionKeyForInstallation, makeInstallationOctokit } from "./client";
import type { AgentActionMode } from "../settings/agent-execution";

// Mirrors parseRepoFullName in labels.ts / assignees.ts (#7425): each GitHub-write module keeps its own copy
// rather than importing a shared one, matching the existing house convention for this tiny pure check.
function parseRepoFullName(repoFullName: string): { owner: string; repo: string } {
const parts = repoFullName.split("/");
const owner = parts[0];
const repo = parts[1];
if (parts.length !== 2 || !owner || !repo || /\s/.test(repoFullName)) {
throw new Error(`Invalid repository full name: ${repoFullName}`);
}
return { owner, repo };
}

export type CreateInstallationIssueInput = {
title: string;
body: string;
labels?: string[] | undefined;
};

export type CreatedInstallationIssue = { number: number; url: string };

/**
* Create a GitHub issue via the installation-token path — the local GitHub App key OR the Orb broker,
* whichever this deployment is configured for (createInstallationToken/withInstallationTokenRetry already pick
* the right one transparently, see src/orb/broker-client.ts) — instead of a flat operator PAT. Every other
* GitHub write in this codebase (labels, comments, check-runs) already goes through this path; issue creation
* was the one write left needing a separately-configured PAT with its own write access to whichever repo was
* targeted, rather than following "wherever this App/Orb-installation is installed" (#7425).
*
* Returns null only when the write itself was suppressed by a non-live mode or GitHub's response omits the
* fields a caller needs (mirrors createOrUpdateNamedCheckRun's publishedOutcome, src/github/app.ts) — a genuine
* GitHub API failure (permission gap, 5xx, rate limit) is NOT swallowed here; it propagates via Octokit's
* throw-on-non-2xx so callers can distinguish "nothing to do" from "the write actually failed" and degrade
* however fits their own contract.
*/
export async function createInstallationIssue(
env: Env,
installationId: number,
repoFullName: string,
issue: CreateInstallationIssueInput,
mode: AgentActionMode = "live",
): Promise<CreatedInstallationIssue | null> {
const { owner, repo } = parseRepoFullName(repoFullName);
return withInstallationTokenRetry(env, installationId, async (token) => {
const octokit = makeInstallationOctokit(env, token, mode, githubRateLimitAdmissionKeyForInstallation(installationId));
const response = await octokit.request("POST /repos/{owner}/{repo}/issues", {
owner,
repo,
title: issue.title,
body: issue.body,
...(issue.labels && issue.labels.length > 0 ? { labels: issue.labels } : {}),
});
const data = response.data as { number?: number; html_url?: string; dryRunSuppressed?: boolean };
if (data.dryRunSuppressed) return null;
return data.number && data.html_url ? { number: data.number, url: data.html_url } : null;
});
}
55 changes: 36 additions & 19 deletions src/services/contributor-issue-draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ import {
import type { IssueRecord, RepositoryRecord, RepositorySettings } from "../types";
import { isGlobalAgentPause } from "../settings/agent-execution";
import { isMaintainerAssociation } from "../github/commands";
import { githubHeaders, timeoutFetch } from "../github/client";
import { createInstallationIssue } from "../github/issues";
import { sha256Hex } from "../utils/crypto";
import { jsonString, nowIso, repoParts } from "../utils/json";
import { errorMessage, nowIso } from "../utils/json";
import {
buildCollisionReport,
buildConfigQuality,
Expand Down Expand Up @@ -259,8 +259,10 @@ export async function generateContributorIssueDrafts(
): Promise<ContributorIssueDraftGenerationResult> {
const context = await loadContributorIssueDraftContext(env, repoFullName);
// The caller's dryRun flag, OVERLAID with the global agent kill-switch: a paused/frozen agent must not file
// contributor issues even when a caller passes {dryRun:false}. These POSTs use a raw token outside the
// installation-Octokit dry-run chokepoint (#dry-run-chokepoint), so the brake is applied here. (#audit-rawfetch-pause)
// contributor issues even when a caller passes {dryRun:false}. createGitHubContributorIssue now creates via
// the installation-Octokit path (#7425), but it's only ever invoked from the branch below once dryRun is
// already resolved false -- this gate (not the per-call AgentActionMode) remains the actual brake, so it must
// stay here rather than assuming the Octokit chokepoint alone would catch a paused/frozen agent. (#audit-rawfetch-pause)
// isGlobalAgentFrozen is an absolute fleet-wide brake with no per-repo bypass, same tier as the env-var
// hard stop (isGlobalAgentPause); day-to-day per-repo enable/disable is settings.agentPaused instead.
const dryRun = options.dryRun !== false || isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env));
Expand Down Expand Up @@ -310,7 +312,7 @@ export async function generateContributorIssueDrafts(
continue;
}
if (!dryRun && createRequested) {
const issue = await createGitHubContributorIssue(env, repoFullName, draft);
const issue = await createGitHubContributorIssue(env, repoFullName, draft, context.repo?.installationId);
if (issue) {
draft.status = "created";
draft.issue = issue;
Expand Down Expand Up @@ -552,21 +554,36 @@ async function loadContributorIssueDraftQueueCounts(env: Env, repoFullName: stri
};
}

async function createGitHubContributorIssue(env: Env, repoFullName: string, draft: ContributorIssueDraft): Promise<{ number: number; url: string } | null> {
const token = env.LOOPOVER_CONTRIBUTOR_ISSUE_TOKEN ?? env.LOOPOVER_DRIFT_ISSUE_TOKEN ?? env.GITHUB_PUBLIC_TOKEN;
if (!token) return null;
const { owner, name } = repoParts(repoFullName);
if (!owner || !name) return null;
const response = await timeoutFetch(`https://api.github.com/repos/${owner}/${name}/issues`, {
method: "POST",
headers: githubHeaders({ token }),
body: jsonString({
/**
* Creates via the installation-token/Orb-broker path (src/github/issues.ts) instead of a flat PAT (#7425), so
* this works on any repo the caller's App/Orb is actually installed on with no separate token to configure. No
* installation on this repo (installationId absent) fails closed the same way "no PAT configured" used to.
* Catches broadly: unlike the raw fetch this replaces (which returned a checkable `.ok` flag), Octokit THROWS on
* a non-2xx response or a malformed repoFullName -- callers of this function rely on a null return, never a
* throw, to mark a draft `skipped_create_failed` instead of failing the whole batch.
*/
async function createGitHubContributorIssue(
env: Env,
repoFullName: string,
draft: ContributorIssueDraft,
installationId: number | null | undefined,
): Promise<{ number: number; url: string } | null> {
if (!installationId) return null;
try {
return await createInstallationIssue(env, installationId, repoFullName, {
title: draft.title,
body: draft.body,
labels: draft.labels,
}),
});
if (!response.ok) return null;
const payload = (await response.json()) as { number?: number; html_url?: string };
return payload.number && payload.html_url ? { number: payload.number, url: payload.html_url } : null;
});
} catch (error) {
console.warn(
JSON.stringify({
level: "warn",
event: "contributor_issue_create_failed",
repoFullName,
message: errorMessage(error).slice(0, 200),
}),
);
return null;
}
}
Loading