Integrate OpenHandoff factory workspace (#212)

This commit is contained in:
Nathan Flurry 2026-03-09 14:00:20 -07:00 committed by GitHub
parent 3d9476ed0b
commit bf282199b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
251 changed files with 42824 additions and 692 deletions

View file

@ -0,0 +1,128 @@
export interface ResolveCreateFlowDecisionInput {
task: string;
explicitTitle?: string;
explicitBranchName?: string;
localBranches: string[];
handoffBranches: string[];
}
export interface ResolveCreateFlowDecisionResult {
title: string;
branchName: string;
}
function firstNonEmptyLine(input: string): string {
const lines = input
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
return lines[0] ?? "";
}
export function deriveFallbackTitle(task: string, explicitTitle?: string): string {
const source = (explicitTitle && explicitTitle.trim()) || firstNonEmptyLine(task) || "update handoff";
const explicitPrefixMatch = source.match(/^\s*(feat|fix|docs|refactor):\s+(.+)$/i);
if (explicitPrefixMatch) {
const explicitTypePrefix = explicitPrefixMatch[1]!.toLowerCase();
const explicitSummary = explicitPrefixMatch[2]!
.split("")
.map((char) => (/^[a-zA-Z0-9 -]$/.test(char) ? char : " "))
.join("")
.split(/\s+/)
.filter((token) => token.length > 0)
.join(" ")
.slice(0, 62)
.trim();
return `${explicitTypePrefix}: ${explicitSummary || "update handoff"}`;
}
const lowered = source.toLowerCase();
const typePrefix = lowered.includes("fix") || lowered.includes("bug")
? "fix"
: lowered.includes("doc") || lowered.includes("readme")
? "docs"
: lowered.includes("refactor")
? "refactor"
: "feat";
const cleaned = source
.split("")
.map((char) => (/^[a-zA-Z0-9 -]$/.test(char) ? char : " "))
.join("")
.split(/\s+/)
.filter((token) => token.length > 0)
.join(" ");
const summary = (cleaned || "update handoff").slice(0, 62).trim();
return `${typePrefix}: ${summary}`.trim();
}
export function sanitizeBranchName(input: string): string {
const normalized = input
.toLowerCase()
.split("")
.map((char) => (/^[a-z0-9]$/.test(char) ? char : "-"))
.join("");
let result = "";
let previousDash = false;
for (const char of normalized) {
if (char === "-") {
if (!previousDash && result.length > 0) {
result += char;
}
previousDash = true;
continue;
}
result += char;
previousDash = false;
}
const trimmed = result.replace(/-+$/g, "");
if (trimmed.length <= 50) {
return trimmed;
}
return trimmed.slice(0, 50).replace(/-+$/g, "");
}
export function resolveCreateFlowDecision(
input: ResolveCreateFlowDecisionInput
): ResolveCreateFlowDecisionResult {
const explicitBranch = input.explicitBranchName?.trim();
const title = deriveFallbackTitle(input.task, input.explicitTitle);
const generatedBase = sanitizeBranchName(title) || "handoff";
const branchBase = explicitBranch && explicitBranch.length > 0 ? explicitBranch : generatedBase;
const existingBranches = new Set(input.localBranches.map((value) => value.trim()).filter((value) => value.length > 0));
const existingHandoffBranches = new Set(
input.handoffBranches.map((value) => value.trim()).filter((value) => value.length > 0)
);
const conflicts = (name: string): boolean =>
existingBranches.has(name) || existingHandoffBranches.has(name);
if (explicitBranch && conflicts(branchBase)) {
throw new Error(
`Branch '${branchBase}' already exists. Choose a different --name/--branch value.`
);
}
if (explicitBranch) {
return { title, branchName: branchBase };
}
let candidate = branchBase;
let index = 2;
while (conflicts(candidate)) {
candidate = `${branchBase}-${index}`;
index += 1;
}
return {
title,
branchName: candidate
};
}

View file

@ -0,0 +1,25 @@
import type { AppConfig } from "@openhandoff/shared";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
function expandPath(input: string): string {
if (input.startsWith("~/")) {
return `${homedir()}/${input.slice(2)}`;
}
return input;
}
export function openhandoffDataDir(config: AppConfig): string {
// Keep data collocated with the backend DB by default.
const dbPath = expandPath(config.backend.dbPath);
return resolve(dirname(dbPath));
}
export function openhandoffRepoClonePath(
config: AppConfig,
workspaceId: string,
repoId: string
): string {
return resolve(join(openhandoffDataDir(config), "repos", workspaceId, repoId));
}

View file

@ -0,0 +1,16 @@
interface QueueSendResult {
status: "completed" | "timedOut";
response?: unknown;
}
export function expectQueueResponse<T>(result: QueueSendResult | void): T {
if (!result || result.status === "timedOut") {
throw new Error("Queue command timed out");
}
return result.response as T;
}
export function normalizeMessages<T>(input: T | T[] | null | undefined): T[] {
if (!input) return [];
return Array.isArray(input) ? input : [input];
}

View file

@ -0,0 +1,45 @@
interface RepoLockState {
locked: boolean;
waiters: Array<() => void>;
}
const repoLocks = new Map<string, RepoLockState>();
async function acquireRepoLock(repoPath: string): Promise<() => void> {
let state = repoLocks.get(repoPath);
if (!state) {
state = { locked: false, waiters: [] };
repoLocks.set(repoPath, state);
}
if (!state.locked) {
state.locked = true;
return () => releaseRepoLock(repoPath, state);
}
await new Promise<void>((resolve) => {
state!.waiters.push(resolve);
});
return () => releaseRepoLock(repoPath, state!);
}
function releaseRepoLock(repoPath: string, state: RepoLockState): void {
const next = state.waiters.shift();
if (next) {
next();
return;
}
state.locked = false;
repoLocks.delete(repoPath);
}
export async function withRepoGitLock<T>(repoPath: string, fn: () => Promise<T>): Promise<T> {
const release = await acquireRepoLock(repoPath);
try {
return await fn();
} finally {
release();
}
}

View file

@ -0,0 +1,50 @@
import { createHash } from "node:crypto";
export function normalizeRemoteUrl(remoteUrl: string): string {
let value = remoteUrl.trim();
if (!value) return "";
// Strip trailing slashes to make hashing stable.
value = value.replace(/\/+$/, "");
// GitHub shorthand: owner/repo -> https://github.com/owner/repo.git
if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value)) {
return `https://github.com/${value}.git`;
}
// If a user pastes "github.com/owner/repo", treat it as HTTPS.
if (/^(?:www\.)?github\.com\/.+/i.test(value)) {
value = `https://${value.replace(/^www\./i, "")}`;
}
// Canonicalize GitHub URLs to the repo clone URL (drop /tree/*, issues, etc).
// This makes "https://github.com/owner/repo" and ".../tree/main" map to the same repoId.
try {
if (/^https?:\/\//i.test(value)) {
const url = new URL(value);
const hostname = url.hostname.replace(/^www\./i, "");
if (hostname.toLowerCase() === "github.com") {
const parts = url.pathname.split("/").filter(Boolean);
if (parts.length >= 2) {
const owner = parts[0]!;
const repo = parts[1]!;
const base = `${url.protocol}//${hostname}/${owner}/${repo.replace(/\.git$/i, "")}.git`;
return base;
}
}
// Drop query/fragment for stability.
url.search = "";
url.hash = "";
return url.toString().replace(/\/+$/, "");
}
} catch {
// ignore parse failures; fall through to raw value
}
return value;
}
export function repoIdFromRemote(remoteUrl: string): string {
const normalized = normalizeRemoteUrl(remoteUrl);
return createHash("sha1").update(normalized).digest("hex").slice(0, 16);
}

View file

@ -0,0 +1,60 @@
import { execFileSync, spawnSync } from "node:child_process";
const SYMBOL_RUNNING = "▶";
const SYMBOL_IDLE = "✓";
function stripStatusPrefix(windowName: string): string {
return windowName
.trimStart()
.replace(new RegExp(`^${SYMBOL_RUNNING}\\s+`), "")
.replace(new RegExp(`^${SYMBOL_IDLE}\\s+`), "")
.trim();
}
export function setWindowStatus(branchName: string, status: string): number {
let symbol: string;
if (status === "running") {
symbol = SYMBOL_RUNNING;
} else if (status === "idle") {
symbol = SYMBOL_IDLE;
} else {
return 0;
}
let stdout: string;
try {
stdout = execFileSync(
"tmux",
["list-windows", "-a", "-F", "#{session_name}:#{window_id}:#{window_name}"],
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
);
} catch {
return 0;
}
const lines = stdout.split(/\r?\n/).filter((line) => line.trim().length > 0);
let count = 0;
for (const line of lines) {
const parts = line.split(":", 3);
if (parts.length !== 3) {
continue;
}
const sessionName = parts[0] ?? "";
const windowId = parts[1] ?? "";
const windowName = parts[2] ?? "";
const clean = stripStatusPrefix(windowName);
if (clean !== branchName) {
continue;
}
const newName = `${symbol} ${branchName}`;
spawnSync("tmux", ["rename-window", "-t", `${sessionName}:${windowId}`, newName], {
stdio: "ignore"
});
count += 1;
}
return count;
}