Skip to content
Open
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
132 changes: 18 additions & 114 deletions packages/github-scan/src/github-scan/engine/commands/poll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,32 +26,21 @@
* surface it here.
*/

import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
} from "node:fs";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";

import { appendActivityEvent } from "../runtime/activity-log.js";
import { classifyGitHubScanStatus } from "../runtime/classifier.js";
import { loadGitHubScanConfig } from "../runtime/config.js";
import { GhClient, GhExecError } from "../runtime/gh.js";
import { GhClient, GhExecError, splitConcatenatedJsonArrays } from "../runtime/gh.js";

export { splitConcatenatedJsonArrays };
import { resolveGitHubScanPaths } from "../runtime/paths.js";
import { RepoFilter } from "../runtime/repo-filter.js";
import { updateInbox } from "../runtime/store.js";
import { shouldTrackReason } from "../runtime/task-kind.js";
import {
parseAllowRepoArg,
requireExplicitRepoFilter,
} from "../runtime/allow-repo.js";
import {
type GhState,
type Inbox,
type InboxEntry,
} from "../runtime/types.js";
import { parseAllowRepoArg, requireExplicitRepoFilter } from "../runtime/allow-repo.js";
import { type GhState, type Inbox, type InboxEntry } from "../runtime/types.js";

export interface PollIO {
stdout: (line: string) => void;
Expand Down Expand Up @@ -175,8 +164,7 @@ export function parseNotifications(
const lastActor = item.subject?.latest_comment_url ?? url ?? "";
const updatedAt = item.updated_at ?? "";
const unread = Boolean(item.unread);
const number =
typeof url === "string" ? extractTrailingNumber(url) : null;
const number = typeof url === "string" ? extractTrailingNumber(url) : null;
const htmlUrl = htmlUrlFor(host, repo, subjectType, number);
seenIds.add(id);
entries.push({
Expand Down Expand Up @@ -267,13 +255,9 @@ function parseLabelResponse(
if (typeof number !== "number") continue;
const rawState = node.state;
const gh_state: GhState | null =
rawState === "OPEN" || rawState === "CLOSED" || rawState === "MERGED"
? rawState
: null;
rawState === "OPEN" || rawState === "CLOSED" || rawState === "MERGED" ? rawState : null;
const labelNodes = node.labels?.nodes ?? [];
const labels = labelNodes
.map((n) => n?.name)
.filter((n): n is string => typeof n === "string");
const labels = labelNodes.map((n) => n?.name).filter((n): n is string => typeof n === "string");
rows.push({ number, gh_state, labels });
}
return rows;
Expand All @@ -287,11 +271,7 @@ function parseLabelResponse(
* Mutates `entries` in place. Returns a warning string if any repo's
* enrichment failed; otherwise `null`.
*/
export function enrichWithLabels(
entries: InboxEntry[],
gh: GhClient,
host: string,
): string | null {
export function enrichWithLabels(entries: InboxEntry[], gh: GhClient, host: string): string | null {
const byRepo = new Map<string, Array<{ number: number; isPR: boolean }>>();
for (const entry of entries) {
if (entry.number === null) continue;
Expand All @@ -316,25 +296,15 @@ export function enrichWithLabels(
const sorted = [...items].sort((a, b) => a.number - b.number);
const deduped: Array<{ number: number; isPR: boolean }> = [];
for (const item of sorted) {
if (
deduped.length === 0 ||
deduped[deduped.length - 1].number !== item.number
) {
if (deduped.length === 0 || deduped[deduped.length - 1].number !== item.number) {
deduped.push(item);
}
}

for (let i = 0; i < deduped.length; i += LABEL_BATCH_SIZE) {
const batch = deduped.slice(i, i + LABEL_BATCH_SIZE);
const query = buildLabelQuery(owner, name, batch);
const result = gh.run([
"api",
"graphql",
"-H",
`GH-Host: ${host}`,
"-f",
`query=${query}`,
]);
const result = gh.run(["api", "graphql", "-H", `GH-Host: ${host}`, "-f", `query=${query}`]);
if (result.status !== 0) {
warnings.push(`GraphQL label enrichment for ${repo} failed`);
continue;
Expand Down Expand Up @@ -385,10 +355,7 @@ interface DiffEvent {
* - `transition` event when `github_scan_status` changed, EXCEPT
* `new → done` (auto-close/merge noise; spec 3 §8)
*/
export function diffEvents(
old: Inbox | null,
next: readonly InboxEntry[],
): DiffEvent[] {
export function diffEvents(old: Inbox | null, next: readonly InboxEntry[]): DiffEvent[] {
const prevStatuses = new Map<string, InboxEntry["github_scan_status"]>();
if (old) {
for (const entry of old.notifications) {
Expand All @@ -415,11 +382,7 @@ export function diffEvents(
}

/** Remove claim directories whose `claimed_at` is older than `timeoutSecs`. */
function cleanupExpiredClaims(
claimsDir: string,
timeoutSecs: number,
now: () => Date,
): void {
function cleanupExpiredClaims(claimsDir: string, timeoutSecs: number, now: () => Date): void {
if (!existsSync(claimsDir)) return;
let dirs: string[];
try {
Expand All @@ -433,9 +396,7 @@ function cleanupExpiredClaims(
if (!existsSync(marker)) continue;
try {
const contents = readFileSync(marker, "utf-8").trim();
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z$/u.exec(
contents,
);
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z$/u.exec(contents);
let claimedMs: number | null = null;
if (m) {
claimedMs = Date.UTC(
Expand All @@ -461,56 +422,6 @@ function cleanupExpiredClaims(
}
}

/**
* Split `gh api --paginate` stdout into individual JSON array pages.
*
* `gh` concatenates paginated arrays as `[...][...][...]` with no
* separator. We walk the string and track bracket depth to carve out each
* top-level array.
*/
export function splitConcatenatedJsonArrays(raw: string): string[] {
const pages: string[] = [];
let depth = 0;
let start = -1;
let inString = false;
let escape = false;
for (let i = 0; i < raw.length; i += 1) {
const ch = raw[i];
if (inString) {
if (escape) {
escape = false;
} else if (ch === "\\") {
escape = true;
} else if (ch === '"') {
inString = false;
}
continue;
}
if (ch === '"') {
inString = true;
continue;
}
if (ch === "[") {
if (depth === 0) start = i;
depth += 1;
continue;
}
if (ch === "]") {
depth -= 1;
if (depth === 0 && start >= 0) {
pages.push(raw.slice(start, i + 1));
start = -1;
}
}
}
if (pages.length === 0 && raw.trim().length > 0) {
// Not a recognizable array stream — return the raw text as a single
// page; the parser will drop it if malformed.
pages.push(raw);
}
return pages;
}

/**
* Entry point for `first-tree github scan poll`.
*
Expand All @@ -521,10 +432,7 @@ export function splitConcatenatedJsonArrays(raw: string): string[] {
* schema validation failure on the existing inbox).
*/
// oxlint-disable-next-line complexity
export async function runPoll(
argv: readonly string[],
deps: PollDeps = {},
): Promise<number> {
export async function runPoll(argv: readonly string[], deps: PollDeps = {}): Promise<number> {
if (argv[0] === "--help" || argv[0] === "-h" || argv[0] === "help") {
const io = deps.io ?? DEFAULT_IO;
io.stdout("Usage: first-tree github scan poll");
Expand All @@ -541,9 +449,7 @@ export async function runPoll(
const paths = deps.paths ?? resolveGitHubScanPaths();
const repoFilter = (() => {
const allowRepo = parseAllowRepoArg(argv);
return allowRepo?.trim()
? requireExplicitRepoFilter(allowRepo)
: RepoFilter.empty();
return allowRepo?.trim() ? requireExplicitRepoFilter(allowRepo) : RepoFilter.empty();
})();
const gh = deps.gh ?? new GhClient();
const now = deps.now ?? (() => new Date());
Expand All @@ -556,9 +462,7 @@ export async function runPoll(
const authCheck = gh.run(["auth", "status"]);
if (authCheck.status !== 0) {
const firstLine = authCheck.stderr.split("\n")[0]?.trim() ?? "";
io.stderr(
`ERROR: gh not authenticated (run \`gh auth login\`). ${firstLine}`.trim(),
);
io.stderr(`ERROR: gh not authenticated (run \`gh auth login\`). ${firstLine}`.trim());
return 1;
}

Expand Down
Loading
Loading