323 lines
9.1 KiB
JavaScript
323 lines
9.1 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { execFile } from "node:child_process";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const issue = "pikasTech/HWLAB#51";
|
|
const supports = ["pikasTech/HWLAB#7", "pikasTech/HWLAB#42", "pikasTech/HWLAB#39"];
|
|
const defaultTimeoutMs = 5000;
|
|
const githubApiBase = "https://api.github.com";
|
|
|
|
function parseArgs(argv) {
|
|
const args = {
|
|
timeoutMs: defaultTimeoutMs,
|
|
help: false
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === "--timeout-ms") {
|
|
args.timeoutMs = Number.parseInt(argv[++index], 10);
|
|
} else if (arg === "--help") {
|
|
args.help = true;
|
|
} else {
|
|
throw new Error(`unknown argument ${arg}`);
|
|
}
|
|
}
|
|
|
|
assert.ok(Number.isInteger(args.timeoutMs) && args.timeoutMs > 0, "--timeout-ms must be positive");
|
|
return args;
|
|
}
|
|
|
|
function usage() {
|
|
return "Usage: node scripts/runner-issue-visibility-preflight.mjs [--timeout-ms 5000]";
|
|
}
|
|
|
|
function commandLine(command, args) {
|
|
return [command, ...args].join(" ");
|
|
}
|
|
|
|
async function run(command, args = [], options = {}) {
|
|
const commandText = commandLine(command, args);
|
|
try {
|
|
const result = await execFileAsync(command, args, {
|
|
cwd: repoRoot,
|
|
timeout: options.timeoutMs ?? defaultTimeoutMs,
|
|
maxBuffer: 1024 * 1024
|
|
});
|
|
return {
|
|
ok: true,
|
|
command: commandText,
|
|
exitCode: 0,
|
|
stdout: result.stdout.trim(),
|
|
stderr: result.stderr.trim()
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
command: commandText,
|
|
exitCode: typeof error.code === "number" ? error.code : 1,
|
|
stdout: String(error.stdout ?? "").trim(),
|
|
stderr: String(error.stderr ?? "").trim(),
|
|
error: error instanceof Error ? error.message : String(error)
|
|
};
|
|
}
|
|
}
|
|
|
|
async function readRemoteUrl() {
|
|
return run("git", ["remote", "get-url", "origin"]);
|
|
}
|
|
|
|
async function readHeadCommit() {
|
|
return run("git", ["rev-parse", "--short=12", "HEAD"]);
|
|
}
|
|
|
|
async function probeGitTransport(timeoutMs) {
|
|
const result = await run("git", ["ls-remote", "origin", "main"], { timeoutMs });
|
|
const line = result.stdout.split("\n").find(Boolean) ?? "";
|
|
const [commitId, ref] = line.split("\t");
|
|
return {
|
|
...result,
|
|
ref: ref ?? null,
|
|
commitId: commitId ?? null,
|
|
reachable: result.ok && Boolean(commitId) && Boolean(ref)
|
|
};
|
|
}
|
|
|
|
function parseGithubSlug(remoteUrl) {
|
|
const patterns = [
|
|
/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/iu,
|
|
/^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/iu,
|
|
/^ssh:\/\/git@github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/iu
|
|
];
|
|
|
|
for (const pattern of patterns) {
|
|
const match = remoteUrl.match(pattern);
|
|
if (match) {
|
|
return `${match[1]}/${match[2]}`;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function probeGithubEndpoint(url, timeoutMs) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: "GET",
|
|
headers: {
|
|
accept: "application/vnd.github+json",
|
|
"user-agent": "hwlab-runner-gh-visibility-preflight"
|
|
},
|
|
signal: controller.signal
|
|
});
|
|
const body = await response.text();
|
|
return {
|
|
url,
|
|
reachable: response.ok,
|
|
ok: response.ok,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
bodyPreview: body.trim().slice(0, 240)
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
url,
|
|
reachable: false,
|
|
ok: false,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
};
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
async function checkGh() {
|
|
return run("gh", ["--version"]);
|
|
}
|
|
|
|
function tokenPresence(name) {
|
|
return Boolean(process.env[name] && String(process.env[name]).trim().length > 0);
|
|
}
|
|
|
|
function blocker(type, scope, summary, nextTask) {
|
|
const entry = {
|
|
type,
|
|
scope,
|
|
status: "open",
|
|
summary
|
|
};
|
|
if (nextTask) {
|
|
entry.nextTask = nextTask;
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
function promptContract() {
|
|
return {
|
|
issueIsAuxiliaryOnly: true,
|
|
selfContainedPromptRequired: true,
|
|
requiredSections: [
|
|
"repository",
|
|
"branch",
|
|
"task",
|
|
"background",
|
|
"constraints",
|
|
"acceptance",
|
|
"validation",
|
|
"final-response"
|
|
],
|
|
mustInclude: [
|
|
"full task objective",
|
|
"relevant background facts",
|
|
"hard constraints",
|
|
"acceptance criteria",
|
|
"exact validation commands",
|
|
"issue context pasted into the prompt when needed"
|
|
],
|
|
mustNotAssume: [
|
|
"issue comments are readable in the runner",
|
|
"GitHub REST issue visibility is available",
|
|
"GitHub REST pull-request visibility is available",
|
|
"gh is installed",
|
|
"GH_TOKEN or GITHUB_TOKEN is present"
|
|
]
|
|
};
|
|
}
|
|
|
|
export async function runRunnerIssueVisibilityPreflight(argv) {
|
|
const args = parseArgs(argv);
|
|
if (args.help) {
|
|
return {
|
|
reportKind: "runner-gh-visibility-preflight",
|
|
usage: usage()
|
|
};
|
|
}
|
|
|
|
const [remoteUrl, headCommit, gitTransport, ghResult] = await Promise.all([
|
|
readRemoteUrl(),
|
|
readHeadCommit(),
|
|
probeGitTransport(args.timeoutMs),
|
|
checkGh()
|
|
]);
|
|
|
|
const repoSlug = remoteUrl.ok ? parseGithubSlug(remoteUrl.stdout) : null;
|
|
const tokenFlags = {
|
|
GH_TOKEN: tokenPresence("GH_TOKEN"),
|
|
GITHUB_TOKEN: tokenPresence("GITHUB_TOKEN")
|
|
};
|
|
|
|
const github = {
|
|
repo: null,
|
|
issues: null,
|
|
pulls: null
|
|
};
|
|
|
|
if (repoSlug) {
|
|
github.repo = await probeGithubEndpoint(`${githubApiBase}/repos/${repoSlug}`, args.timeoutMs);
|
|
github.issues = await probeGithubEndpoint(`${githubApiBase}/repos/${repoSlug}/issues?per_page=1`, args.timeoutMs);
|
|
github.pulls = await probeGithubEndpoint(`${githubApiBase}/repos/${repoSlug}/pulls?per_page=1`, args.timeoutMs);
|
|
}
|
|
|
|
const blockers = [];
|
|
if (!remoteUrl.ok) {
|
|
blockers.push(
|
|
blocker(
|
|
"environment_blocker",
|
|
"git-origin",
|
|
`Unable to read the origin remote URL: ${remoteUrl.stderr || remoteUrl.error || "unknown error"}.`,
|
|
"Fix the local git remote before queueing a runner task."
|
|
)
|
|
);
|
|
}
|
|
if (!gitTransport.reachable) {
|
|
blockers.push(
|
|
blocker(
|
|
"environment_blocker",
|
|
"git-transport",
|
|
`git ls-remote origin main is not reachable from this runner: ${gitTransport.stderr || gitTransport.error || "unknown error"}.`,
|
|
"Restore git transport reachability before relying on runner-side GitHub visibility."
|
|
)
|
|
);
|
|
}
|
|
if (!repoSlug) {
|
|
blockers.push(
|
|
blocker(
|
|
"observability_blocker",
|
|
"github-remote-parse",
|
|
`Could not parse a GitHub repo slug from origin URL ${JSON.stringify(remoteUrl.stdout || "")}.`,
|
|
"Use a GitHub origin remote URL that can be mapped to owner/repo."
|
|
)
|
|
);
|
|
} else {
|
|
for (const [scope, result] of [
|
|
["github-repo", github.repo],
|
|
["github-issues", github.issues],
|
|
["github-pulls", github.pulls]
|
|
]) {
|
|
if (result && !result.reachable) {
|
|
blockers.push(
|
|
blocker(
|
|
"observability_blocker",
|
|
scope,
|
|
`${scope} is not reachable from this runner${typeof result.status === "number" ? ` (HTTP ${result.status})` : `: ${result.error || "unknown error"}`}.`,
|
|
"Do not assume issue or PR text is available; keep the prompt self-contained."
|
|
)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const conclusion = blockers.length === 0 ? "ready" : "blocked";
|
|
const upstreamRecommendations = [];
|
|
if (ghResult.ok === false) {
|
|
upstreamRecommendations.push("Add gh to the runner image or another supported GitHub visibility helper.");
|
|
}
|
|
if (!tokenFlags.GH_TOKEN && !tokenFlags.GITHUB_TOKEN) {
|
|
upstreamRecommendations.push("Inject a read-only GH_TOKEN or GITHUB_TOKEN when GitHub issue or PR reads are required.");
|
|
}
|
|
if (blockers.some((item) => item.scope.startsWith("github-") || item.scope === "git-transport")) {
|
|
upstreamRecommendations.push("Add a queue preflight that checks git transport, repo, issue, and PR visibility before dispatch.");
|
|
upstreamRecommendations.push("Fail fast when GitHub visibility is missing and require the prompt to include the full task context.");
|
|
}
|
|
|
|
return {
|
|
reportKind: "runner-gh-visibility-preflight",
|
|
reportVersion: "v1",
|
|
issue,
|
|
supports,
|
|
repoRoot,
|
|
repoSlug,
|
|
generatedFromCommit: headCommit.ok ? headCommit.stdout : null,
|
|
conclusion,
|
|
promptContract: promptContract(),
|
|
checks: {
|
|
remoteUrl,
|
|
headCommit,
|
|
gitTransport,
|
|
gh: {
|
|
available: ghResult.ok,
|
|
command: ghResult.command,
|
|
exitCode: ghResult.exitCode,
|
|
stderr: ghResult.stderr,
|
|
error: ghResult.error || null
|
|
},
|
|
tokenPresence: tokenFlags,
|
|
github
|
|
},
|
|
blockers,
|
|
upstreamRecommendations,
|
|
validationCommands: [
|
|
"node --check scripts/runner-issue-visibility-preflight.mjs",
|
|
"node --check scripts/src/runner-issue-visibility-preflight.mjs",
|
|
"node scripts/runner-issue-visibility-preflight.mjs"
|
|
]
|
|
};
|
|
}
|
|
|