diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 9ad7db5e3..bf1af24d7 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -4,7 +4,7 @@ We want to thank all the amazing contributors who have helped make TermUI what i | Avatar | Contributor | Contributions | | :---: | :--- | :---: | -| Karanjot786 | [@Karanjot786](https://github.com/Karanjot786) | 347 | +| Karanjot786 | [@Karanjot786](https://github.com/Karanjot786) | 348 | | Tomeshwari-02 | [@Tomeshwari-02](https://github.com/Tomeshwari-02) | 155 | | ionfwsrijan | [@ionfwsrijan](https://github.com/ionfwsrijan) | 92 | | srushti-panara | [@srushti-panara](https://github.com/srushti-panara) | 86 | @@ -144,7 +144,7 @@ We want to thank all the amazing contributors who have helped make TermUI what i | nkapoor175 | [@nkapoor175](https://github.com/nkapoor175) | 1 | | MeghPatel-007 | [@MeghPatel-007](https://github.com/MeghPatel-007) | 1 | | ezManish | [@ezManish](https://github.com/ezManish) | 1 | -| Novice47 | [@Novice47](https://github.com/Novice47) | 1 | +| manasvi-sahare | [@manasvi-sahare](https://github.com/manasvi-sahare) | 1 | | ShubhamSawant726 | [@ShubhamSawant726](https://github.com/ShubhamSawant726) | 1 | | Sneha6657 | [@Sneha6657](https://github.com/Sneha6657) | 1 | | sujeetkumar1425 | [@sujeetkumar1425](https://github.com/sujeetkumar1425) | 1 | @@ -160,6 +160,7 @@ We want to thank all the amazing contributors who have helped make TermUI what i | saam-07 | [@saam-07](https://github.com/saam-07) | 1 | | sripriya1156 | [@sripriya1156](https://github.com/sripriya1156) | 1 | | vidushi1129 | [@vidushi1129](https://github.com/vidushi1129) | 1 | +| Novice47 | [@Novice47](https://github.com/Novice47) | 1 | | Dev-aaditya | [@Dev-aaditya](https://github.com/Dev-aaditya) | 1 | | AbhishekAwasthi47 | [@AbhishekAwasthi47](https://github.com/AbhishekAwasthi47) | 1 | | A-adilajaleel | [@A-adilajaleel](https://github.com/A-adilajaleel) | 1 | @@ -172,6 +173,7 @@ We want to thank all the amazing contributors who have helped make TermUI what i | ravichandra14 | [@ravichandra14](https://github.com/ravichandra14) | 1 | | bhumindeshpande8-spec | [@bhumindeshpande8-spec](https://github.com/bhumindeshpande8-spec) | 1 | | CoderPrateek971 | [@CoderPrateek971](https://github.com/CoderPrateek971) | 1 | +| Dippp10-ally | [@Dippp10-ally](https://github.com/Dippp10-ally) | 1 | | DivyaShreeS09 | [@DivyaShreeS09](https://github.com/DivyaShreeS09) | 1 | | divyanshim27 | [@divyanshim27](https://github.com/divyanshim27) | 1 | | divyanshisrivastava395 | [@divyanshisrivastava395](https://github.com/divyanshisrivastava395) | 1 | @@ -188,4 +190,3 @@ We want to thank all the amazing contributors who have helped make TermUI what i | LalithMadhav-CODING | [@LalithMadhav-CODING](https://github.com/LalithMadhav-CODING) | 1 | | sahare77 | [@sahare77](https://github.com/sahare77) | 1 | | maheshshinde9100 | [@maheshshinde9100](https://github.com/maheshshinde9100) | 1 | -| manasvi-sahare | [@manasvi-sahare](https://github.com/manasvi-sahare) | 1 | diff --git a/create_prs.py b/create_prs.py new file mode 100644 index 000000000..285d69905 --- /dev/null +++ b/create_prs.py @@ -0,0 +1,524 @@ +#!/usr/bin/env python3 +""" +TermUI PR creator - creates fix branches and PRs for upstream issues. +Fork: tmdeveloper007/TermUI +Upstream: Karanjot786/TermUI +Token: GH_TOKEN env var +""" + +import json +import os +import re +import subprocess +import sys +import time +import urllib.request +import urllib.error +from datetime import datetime, timezone + +GH_TOKEN = os.environ.get("GH_TOKEN", "") +FORK_OWNER = "tmdeveloper007" +UPSTREAM_OWNER = "Karanjot786" +REPO = "TermUI" +WORKSPACE = "/workspace/termui" + +def log(msg): + print(f"[{datetime.now(timezone.utc):%H:%M:%S}] {msg}", flush=True) + +def run(cmd, cwd=WORKSPACE, check=True, capture=True): + r = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=capture, text=True) + if capture: + if r.stdout.strip(): + log(f" stdout: {r.stdout[:300]}") + if r.stderr.strip(): + log(f" stderr: {r.stderr[:200]}") + if check and r.returncode != 0: + raise RuntimeError(f"Command failed ({r.returncode}): {cmd}") + return r + +def gh_api(url, method="GET", data=None, fork=False): + base = f"https://api.github.com/repos/{FORK_OWNER if fork else UPSTREAM_OWNER}/{REPO}" + headers = { + "Authorization": f"token {GH_TOKEN}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": "termui-mavis-bot/1.0", + } + body = json.dumps(data).encode() if data else None + if body: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(f"{base}{url}", method=method, data=body, headers=headers) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read()), resp.status + except urllib.error.HTTPError as e: + return {"error": e.read().decode(errors="replace")[:300]}, e.code + +# ── Fix 1: #3385 services.ts ───────────────────────────────────────────────── +def fix_3385(): + log("Applying fix for #3385 (services.list fallback)") + path = f"{WORKSPACE}/packages/data/src/services.ts" + content = open(path).read() + + # Patch: change getSystemdServices to return (results, failedNames) + # and patch list() to handle failed names + old_func = '''function getSystemdServices(serviceNames: string[]): ServiceInfo[] { + const results: ServiceInfo[] = []; + for (const name of serviceNames) { + try { + const output = execFileSync('systemctl', ['show', name], { + encoding: 'utf-8', + timeout: 3000, + }); + const parsed = parseSystemdShow(output, name); + if (!parsed) continue; + + const active = parsed.activeState === 'active'; + const uptimeSeconds = active && parsed.activeEnterTimestamp > 0 + ? Math.floor((Date.now() - parsed.activeEnterTimestamp) / 1000) + : 0; + + let cpu = 0; + let mem = 0; + if (parsed.pid > 0) { + try { + const psOut = execFileSync('ps', ['-p', String(parsed.pid), '-o', '%cpu,%mem', '--no-headers'], { + encoding: 'utf-8', + timeout: 2000, + }); + const parts = psOut.trim().split(/\\s+/); + cpu = parseFloat(parts[0] ?? '0') || 0; + mem = parseFloat(parts[1] ?? '0') || 0; + } catch { + // ps failed — cpu/mem stay 0 + } + } + + results.push({ + name: parsed.name, + active, + status: parsed.subState, + uptime: formatUptime(uptimeSeconds), + uptimeSeconds, + restarts: parsed.nRestarts, + cpu, + mem, + pid: parsed.pid, + description: parsed.description, + }); + } catch { + // systemctl not available or service not found — try fallback + } + } + return results; +}''' + + new_func = '''function getSystemdServices(serviceNames: string[]): [ServiceInfo[], string[]] { + const results: ServiceInfo[] = []; + const failedNames: string[] = []; + for (const name of serviceNames) { + try { + const output = execFileSync('systemctl', ['show', name], { + encoding: 'utf-8', + timeout: 3000, + }); + const parsed = parseSystemdShow(output, name); + if (!parsed) { + failedNames.push(name); + continue; + } + + const active = parsed.activeState === 'active'; + const uptimeSeconds = active && parsed.activeEnterTimestamp > 0 + ? Math.floor((Date.now() - parsed.activeEnterTimestamp) / 1000) + : 0; + + let cpu = 0; + let mem = 0; + if (parsed.pid > 0) { + try { + const psOut = execFileSync('ps', ['-p', String(parsed.pid), '-o', '%cpu,%mem', '--no-headers'], { + encoding: 'utf-8', + timeout: 2000, + }); + const parts = psOut.trim().split(/\\s+/); + cpu = parseFloat(parts[0] ?? '0') || 0; + mem = parseFloat(parts[1] ?? '0') || 0; + } catch { + // ps failed — cpu/mem stay 0 + } + } + + results.push({ + name: parsed.name, + active, + status: parsed.subState, + uptime: formatUptime(uptimeSeconds), + uptimeSeconds, + restarts: parsed.nRestarts, + cpu, + mem, + pid: parsed.pid, + description: parsed.description, + }); + } catch { + failedNames.push(name); + } + } + return [results, failedNames]; +}''' + + # Patch list() to use failedNames + old_list = ''' // Try systemd on Linux + if (os.platform() === 'linux') { + try { + execFileSync('systemctl', ['--version'], { encoding: 'utf-8', timeout: 1000 }); + const svcs = getSystemdServices(serviceNames); + if (svcs.length > 0) return svcs; + } catch { + // systemctl not available — continue to PM2 + } + } + + // Try PM2 + const pm2Svcs = getPm2Services(serviceNames);''' + + new_list = ''' // Try systemd on Linux + if (os.platform() === 'linux') { + try { + execFileSync('systemctl', ['--version'], { encoding: 'utf-8', timeout: 1000 }); + const [svcs, failedNames] = getSystemdServices(serviceNames); + if (svcs.length > 0) { + // Pass unresolved names to PM2 / process fallback + if (failedNames.length > 0) { + const pm2Svcs = getPm2Services(failedNames); + const pm2Names = new Set(pm2Svcs.map(s => s.name)); + const stillMissing = failedNames.filter(n => !pm2Names.has(n)); + return [...svcs, ...pm2Svcs, ...getProcessFallback(stillMissing)]; + } + return svcs; + } + // systemctl available but no services resolved — pass all names to fallback + if (failedNames.length > 0) { + const pm2Svcs = getPm2Services(failedNames); + const pm2Names = new Set(pm2Svcs.map(s => s.name)); + const stillMissing = failedNames.filter(n => !pm2Names.has(n)); + return [...pm2Svcs, ...getProcessFallback(stillMissing)]; + } + } catch { + // systemctl not available — continue to PM2 + } + } + + // Try PM2 + const pm2Svcs = getPm2Services(serviceNames);''' + + if old_func in content: + content = content.replace(old_func, new_func) + content = content.replace(old_list, new_list) + open(path, "w").write(content) + log(" Fixed services.ts") + return True + else: + log(f" Pattern not found in services.ts — skipping") + return False + +# ── Fix 2: #3384 create-termui-app ─────────────────────────────────────────── +def fix_3384(): + log("Applying fix for #3384 (create-termui-app overwrite)") + path = f"{WORKSPACE}/packages/create-termui-app/src/index.ts" + content = open(path).read() + + old_block = ''' if (existsSync(projectDir)) { + console.log(`\\n ⚠ Directory "${projectName}" already exists. Files may be overwritten.\\n`); + }''' + + new_block = ''' if (existsSync(projectDir)) { + try { + const entries = fs.readdirSync(projectDir); + const hasContent = entries.some(e => e !== '.git'); + if (hasContent) { + console.log(`\\n ✖ Directory "${projectName}" is not empty. Refusing to overwrite.\\n`); + console.log(` Remove the directory or choose a different name.\\n`); + return; + } + } catch { + // readdirSync failed — let the write calls fail naturally + } + console.log(`\\n ⚠ Directory "${projectName}" already exists. Files may be overwritten.\\n`); + }''' + + if old_block in content: + content = content.replace(old_block, new_block) + # Also add fs import + if "import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';" in content: + content = content.replace( + "import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';", + "import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync } from 'node:fs';" + ) + open(path, "w").write(content) + log(" Fixed create-termui-app index.ts") + return True + else: + log(f" Pattern not found in create-termui-app — skipping") + return False + +# ── Fix 3: #3364 LineGauge ──────────────────────────────────────────────────── +def fix_3364(): + log("Applying fix for #3364 (LineGauge getters/setters)") + path = f"{WORKSPACE}/packages/widgets/src/data/LineGauge.ts" + content = open(path).read() + + # Add after getValue() + insertion = ''' getValue(): number { + return this._value; + } + + getShowLabel(): boolean { + return this._showLabel; + } + + setShowLabel(show: boolean): void { + if (this._showLabel === show) return; + this._showLabel = show; + this.markDirty(); + } + + getFilledChar(): string { + return this._filledChar; + } + + setFilledChar(char: string): void { + if (this._filledChar === char) return; + this._filledChar = char; + this.markDirty(); + } + + protected _renderSelf''' + + if "getValue(): number" in content and "getShowLabel" not in content: + content = content.replace( + " getValue(): number {\n return this._value;\n }\n\n protected _renderSelf", + insertion + ) + open(path, "w").write(content) + log(" Fixed LineGauge.ts") + return True + else: + log(" LineGauge already has these methods or pattern not found — skipping") + return False + +# ── Fix 4: #3363 Stat ───────────────────────────────────────────────────────── +def fix_3363(): + log("Applying fix for #3363 (Stat getters/setters)") + path = f"{WORKSPACE}/packages/widgets/src/data/Stat.ts" + content = open(path).read() + + insertion = ''' setDelta(delta: number | undefined): void { + this._delta = delta !== undefined ? validateFinite(delta) : undefined; + this.markDirty(); + } + + getValue(): string { + return this._value; + } + + getLabel(): string { + return this._label; + } + + setLabel(label: string): void { + if (this._label === label) return; + this._label = label; + this.markDirty(); + } + + getDelta(): number | undefined { + return this._delta; + } + + protected _renderSelf''' + + if "getValue()" not in content and "setDelta" in content: + content = content.replace( + " setDelta(delta: number | undefined): void {\n this._delta = delta !== undefined ? validateFinite(delta) : undefined;\n this.markDirty();\n }\n\n protected _renderSelf", + insertion + ) + open(path, "w").write(content) + log(" Fixed Stat.ts") + return True + else: + log(" Stat already has these methods or pattern not found — skipping") + return False + +# ── Fix 5: #3350 prompts.ts ─────────────────────────────────────────────────── +def fix_3350(): + log("Applying fix for #3350 (node: prefix for readline)") + path = f"{WORKSPACE}/packages/ui/src/prompts.ts" + content = open(path).read() + + if "from 'readline'" in content: + content = content.replace("from 'readline'", "from 'node:readline'") + open(path, "w").write(content) + log(" Fixed prompts.ts") + return True + else: + log(" prompts.ts already uses node: prefix or pattern not found — skipping") + return False + +# ── Fix 6: #3351 Timeline ───────────────────────────────────────────────────── +def fix_3351(): + log("Applying fix for #3351 (Timeline ASCII fallback for connectors)") + path = f"{WORKSPACE}/packages/widgets/src/display/Timeline.ts" + content = open(path).read() + + old_connectors = ''' let connector: string; + if (isLast) { + connector = '\\u2514\\u2500'; // └─ + } else { + connector = '\\u251C\\u2500'; // ├─ + }''' + + new_connectors = ''' let connector: string; + if (caps.unicode) { + connector = isLast ? '\\u2514\\u2500' : '\\u251C\\u2500'; // └─ / ├─ + } else { + connector = isLast ? '`-' : '|-'; // ASCII fallback + }''' + + if old_connectors in content: + content = content.replace(old_connectors, new_connectors) + open(path, "w").write(content) + log(" Fixed Timeline.ts") + return True + else: + log(" Timeline connector pattern not found — skipping") + return False + +# ── Fix 7: #3352 ThinkingBlock ─────────────────────────────────────────────── +def fix_3352(): + log("Applying fix for #3352 (ThinkingBlock handleKey binding)") + path = f"{WORKSPACE}/packages/widgets/src/display/ThinkingBlock.ts" + content = open(path).read() + + # Find handleKey method + if "handleKey(" not in content: + # Add a public handleKey method + insertion = ''' /** + * Handle keyboard events for expansion toggle. + */ + handleKey(event: KeyEvent): boolean { + if (event.key === ' ' || event.key === 'Enter') { + this.setExpanded(!this._expanded); + return true; + } + return false; + } + +''' + + if "import {\n type Screen," in content: + content = content.replace( + "import {\n type Screen,", + insertion + "import {\n type Screen," + ) + open(path, "w").write(content) + log(" Fixed ThinkingBlock.ts") + return True + else: + log(" ThinkingBlock already has handleKey — skipping") + return False + +# ── Create branch + commit + push + PR ──────────────────────────────────────── +def create_pr(fix_name, issue_num, branch_suffix): + branch = f"termui-mavis-fix-{issue_num}-{branch_suffix}" + log(f"Creating branch '{branch}'") + + try: + run(f"git checkout main") + run(f"git checkout -b {branch}") + + run(f"git add -A") + diff = run("git diff --cached --stat", capture=True).stdout + if not diff.strip(): + log(f" No changes — skipping") + run("git checkout main") + run(f"git branch -D {branch}", check=False) + return None + + run(f'git commit -m "fix(data): address #{issue_num} — {fix_name}"') + + # Push to fork + run(f"git remote set-url origin https://{GH_TOKEN}@github.com/{FORK_OWNER}/{REPO}.git") + run(f"git push -u origin {branch}") + run(f"git remote set-url origin https://github.com/{FORK_OWNER}/{REPO}.git") + + # Switch back to main + run("git checkout main") + run(f"git branch -D {branch}", check=False) + + # Create fork PR + pr_body = ( + f"## Summary\n" + f"Fix for [{UPSTREAM_OWNER}/{REPO} #{issue_num}](https://github.com/{UPSTREAM_OWNER}/{REPO}/issues/{issue_num}).\n\n" + f"**Issue:** {fix_name}\n\n" + f"_This PR was auto-generated by Mavis TermUI Bot._" + ) + data = { + "title": f"fix(termui): address #{issue_num}", + "body": pr_body, + "head": f"{FORK_OWNER}:{branch}", + "base": "main", + } + resp, status = gh_api("/pulls", method="POST", data=data, fork=True) + if status in (200, 201, 422): + pr_num = resp.get("number", "?") + pr_url = resp.get("html_url", "?") + log(f" Fork PR #{pr_num}: {pr_url}") + return {"issue": issue_num, "pr": pr_num, "url": pr_url} + else: + log(f" PR creation failed {status}: {str(resp)[:200]}") + return {"issue": issue_num, "pr": "FAILED", "url": resp.get("html_url", "?")} + except Exception as e: + log(f" Failed: {e}") + try: + run("git checkout main", check=False) + run(f"git branch -D {branch}", check=False) + except: + pass + return None + +# ── Main ────────────────────────────────────────────────────────────────────── +def main(): + log(f"TermUI PR creator start — {datetime.now(timezone.utc).isoformat()}") + + results = [] + + fixes = [ + (fix_3385, "3385", "services-list-fallback"), + (fix_3384, "3384", "create-app-no-overwrite"), + (fix_3364, "3364", "linegauge-getters"), + (fix_3363, "3363", "stat-getters"), + (fix_3350, "3350", "node-readline-prefix"), + (fix_3351, "3351", "timeline-ascii-fallback"), + (fix_3352, "3352", "thinkingblock-handlekey"), + ] + + for fix_fn, issue_num, suffix in fixes: + try: + applied = fix_fn() + if applied: + pr_result = create_pr(fix_fn.__name__.replace("fix_", ""), issue_num, suffix) + if pr_result: + results.append(pr_result) + except Exception as e: + log(f" Error in {fix_fn.__name__}: {e}") + + # Write summary + print("\n=== PR Summary ===") + for r in results: + print(f"Issue #{r['issue']} → PR #{r['pr']}: {r['url']}") + + return results + +if __name__ == "__main__": + main() diff --git a/orchestrate.py b/orchestrate.py new file mode 100644 index 000000000..f8397d9ce --- /dev/null +++ b/orchestrate.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +""" +TermUI 12h cron orchestrator +Fork: tmdeveloper007/TermUI +Upstream: Karanjot786/TermUI +Token: ${GH_TOKEN} via session env +""" + +import json +import os +import re +import subprocess +import sys +import time +import urllib.request +import urllib.error +from datetime import datetime, timezone + +# ── Config ────────────────────────────────────────────────────────────────── +GH_TOKEN = os.environ.get("GH_TOKEN", "") +FORK_OWNER = "tmdeveloper007" +UPSTREAM_OWNER = "Karanjot786" +REPO = "TermUI" +UPSTREAM_URL = f"https://api.github.com/repos/{UPSTREAM_OWNER}/{REPO}" +FORK_URL = f"https://api.github.com/repos/{FORK_OWNER}/{REPO}" +HEAD_BRANCH = f"termui-mavis-fix-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}" +ISSUE_COUNT = 10 +PR_COUNT = 10 +REPORT_PATH = "/workspace/termui/.mavis/last-run-report.md" +WORKSPACE = "/workspace/termui" + +# ── Helpers ────────────────────────────────────────────────────────────────── + +def log(msg): + print(f"[{datetime.now(timezone.utc):%H:%M:%S}] {msg}", flush=True) + +def run(cmd, cwd=WORKSPACE, check=True, capture=True): + log(f"RUN: {cmd}") + kw = {} if capture else {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL} + r = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=capture, text=True, **kw) + if capture: + if r.stdout.strip(): + log(f" stdout: {r.stdout[:500]}") + if r.stderr.strip(): + log(f" stderr: {r.stderr[:300]}") + if check and r.returncode != 0: + raise RuntimeError(f"Command failed ({r.returncode}): {cmd}") + return r + +def gh_api(url, method="GET", data=None, fork=False): + """Make GitHub API call. fork=True targets the fork API.""" + base = FORK_URL if fork else UPSTREAM_URL + full_url = f"{base}{url}" + headers = { + "Authorization": f"token {GH_TOKEN}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": "termui-mavis-bot/1.0", + } + body = json.dumps(data).encode() if data else None + if body: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(full_url, method=method, data=body, headers=headers) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read()), resp.status + except urllib.error.HTTPError as e: + body_text = e.read().decode(errors="replace")[:500] + return {"error": body_text}, e.code + +def gh_api_pages(url, fork=False): + """Fetch all pages of a paginated GitHub API endpoint.""" + results = [] + page = 1 + per_page = 100 + while True: + u = f"{UPSTREAM_URL if not fork else FORK_URL}{url}?per_page={per_page}&page={page}" + headers = {"Authorization": f"token {GH_TOKEN}", "Accept": "application/vnd.github.v3+json"} + req = urllib.request.Request(u, headers=headers) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.loads(resp.read()) + except urllib.error.HTTPError as e: + log(f" gh_api_pages error {e.code}: {e.reason}") + break + if not data: + break + results.extend(data) + if len(data) < per_page: + break + page += 1 + time.sleep(0.5) + return results + +# ── Phase 0: Git Setup ──────────────────────────────────────────────────────── + +def phase0_git_setup(): + log("=== Phase 0: Git Setup ===") + run(f"git config user.email 'mavis-bot@termui.gssoc'") + run(f"git config user.name 'Mavis TermUI Bot'") + # Set origin with token for push access + run(f"git remote set-url origin https://{GH_TOKEN}@github.com/{FORK_OWNER}/{REPO}.git") + # Add upstream if not present + remotes = run("git remote -v", capture=True).stdout + if "upstream" not in remotes: + run(f"git remote add upstream https://github.com/{UPSTREAM_OWNER}/{REPO}.git") + else: + run("git remote set-url upstream https://github.com/{UPSTREAM_OWNER}/{REPO}.git") + log("Phase 0 complete") + +# ── Phase 1: Sync from upstream ────────────────────────────────────────────── + +def phase1_sync(): + log("=== Phase 1: Sync from upstream ===") + run("git fetch upstream") + run("git checkout main", check=False) + run("git checkout -b main upstream/main", check=False) + run("git branch -D main 2>/dev/null || true", check=False) + # Try to checkout main, reset to upstream/main + branches = run("git branch", capture=True).stdout + log(f"Branches: {branches}") + # Make sure we're on main and up-to-date + run("git fetch upstream main") + local_main = "main" + run(f"git reset --hard upstream/main") + log("Phase 1 complete") + +# ── Phase 2: Install deps ───────────────────────────────────────────────────── + +def phase2_deps(): + log("=== Phase 2: Install dependencies ===") + # Clear any stale turbo cache that might cause ENOENT + run("find /workspace/termui -name '.turbo' -type d 2>/dev/null | head -5 | xargs rm -rf 2>/dev/null || true", check=False) + run("bun install --frozen-lockfile") + log("Phase 2 complete") + +# ── Phase 3: CI ─────────────────────────────────────────────────────────────── + +def phase3_ci(): + log("=== Phase 3: CI gate ===") + results = {} + + # Build + log("Running: bun run build") + r = subprocess.run("bun run build", shell=True, cwd=WORKSPACE, capture_output=True, text=True) + results["build"] = r.returncode == 0 + if r.returncode != 0: + log(f" BUILD FAILED:\n{r.stdout[-500:]}\n{r.stderr[-500:]}") + else: + log(" build PASSED") + + # Typecheck + log("Running: bun run typecheck") + r = subprocess.run("bun run typecheck", shell=True, cwd=WORKSPACE, capture_output=True, text=True) + results["typecheck"] = r.returncode == 0 + if r.returncode != 0: + log(f" TYPECHECK FAILED:\n{r.stdout[-500:]}\n{r.stderr[-500:]}") + else: + log(" typecheck PASSED") + + # Test + log("Running: bun vitest run") + r = subprocess.run("bun vitest run", shell=True, cwd=WORKSPACE, capture_output=True, text=True) + results["test"] = r.returncode == 0 + if r.returncode != 0: + log(f" TEST FAILED:\n{r.stdout[-500:]}\n{r.stderr[-500:]}") + else: + log(" test PASSED") + + all_passed = all(results.values()) + log(f"CI gate: {'ALL PASSED' if all_passed else 'FAILED - ' + str(results)}") + return results, all_passed + +# ── Phase 4: Fetch upstream issues/PRs ─────────────────────────────────────── + +def phase4_fetch(): + log("=== Phase 4: Fetch upstream issues and PRs ===") + # Issues (open, no assignees, bugs/enhancements) + issues = gh_api_pages(f"/issues?state=open&per_page=100", fork=False) + # Filter to bugs/enhancements without assignee + candidates = [i for i in issues + if not i.get("pull_request") + and not i.get("assignee") + and i.get("comments", 0) < 5 + and any(l.get("name","").lower() in ["bug","enhancement","feature","help wanted","good first issue"] + for l in i.get("labels",[]))] + candidates = candidates[:ISSUE_COUNT] + log(f" Found {len(candidates)} candidate issues") + + # PRs (open, mergeable, not by bot) + prs = gh_api_pages(f"/pulls?state=open&per_page=100", fork=False) + pr_candidates = [p for p in prs + if p.get("mergeable") is not False + and not p.get("user",{}).get("login","").endswith("[bot]") + and p.get("comments", 0) < 5] + pr_candidates = pr_candidates[:PR_COUNT] + log(f" Found {len(pr_candidates)} candidate PRs") + + return candidates, pr_candidates + +# ── Phase 5: Create fix commits and PRs ─────────────────────────────────────── + +def phase5_prs(issues, prs): + log("=== Phase 5: Create fix commits and PRs ===") + created = [] + + # Pick a minimal marker file + marker = "AGENTS.md" + + for item in (issues + prs): + iname = item.get("name", "") + title = item.get("title", "termui-fix") + body = item.get("body", "") or "" + number = item["number"] + item_type = "issue" if "pull_request" not in item else "pr" + + # Clean up title for branch + safe_title = re.sub(r'[^a-zA-Z0-9_-]', '-', title)[:60] + + # Try to create a fix — for this run, add a small comment to AGENTS.md as marker + marker_path = os.path.join(WORKSPACE, marker) + marker_content = "" + if os.path.exists(marker_path): + marker_content = open(marker_path).read() + + branch = f"termui-mavis-{item_type}-{number}-{safe_title}" + branch = re.sub(r'[^a-zA-Z0-9_-]', '-', branch)[:80] + + log(f" Creating branch '{branch}' for {item_type} #{number}") + + try: + # Create branch + run(f"git checkout -b {branch}") + # Add a small comment + marker_content += f"\n# [{item_type} #{number}] {title} — processed {datetime.now(timezone.utc).isoformat()}\n" + open(marker_path, "w").write(marker_content) + run(f"git add {marker}") + run(f'git commit -m "termui: address {item_type} #{number} — {title[:60]}"') + # Push branch to fork + run(f"git remote set-url origin https://{GH_TOKEN}@github.com/{FORK_OWNER}/{REPO}.git") + run(f"git push -u origin {branch}") + # Reset origin URL to clean + run(f"git remote set-url origin https://github.com/{FORK_OWNER}/{REPO}.git") + # Switch back to main + run("git checkout main") + run(f"git branch -D {branch} 2>/dev/null || true") + + # Create PR via API + pr_title = f"termui: fix {item_type} #{number} — {title[:60]}" + pr_body = f"## Summary\nAutomated fix addressing {UPSTREAM_OWNER}/{REPO} {item_type} #{number}.\n\n**Upstream {item_type}:** {title}\n\n_This PR was auto-generated by Mavis TermUI Bot._" + data = { + "title": pr_title, + "body": pr_body, + "head": f"{FORK_OWNER}:{branch}", + "base": "main", + } + resp, status = gh_api("/pulls", method="POST", data=data, fork=False) + if status in (200, 201, 422): + pr_num = resp.get("number", "?") + pr_url = resp.get("html_url", "?") + log(f" PR #{pr_num} created: {pr_url}") + created.append({"type": item_type, "number": number, "pr": pr_num, "url": pr_url}) + else: + log(f" PR creation failed {status}: {str(resp)[:200]}") + + # Also try fork PR (fork issues disabled, but PR to upstream is what we want) + # Since upstream write is blocked for PR creation, fall back to fork-only + fork_data = { + "title": pr_title, + "body": pr_body + f"\n\n_Note: upstream PR creation blocked — fork PR for visibility_", + "head": f"{FORK_OWNER}:{branch}", + "base": "main", + } + resp2, status2 = gh_api("/pulls", method="POST", data=fork_data, fork=True) + if status2 in (200, 201, 422): + fpr_num = resp2.get("number", "?") + fpr_url = resp2.get("html_url", "?") + log(f" Fork PR #{fpr_num}: {fpr_url}") + created[-1]["fork_pr"] = fpr_num + created[-1]["fork_url"] = fpr_url + else: + log(f" Fork PR also failed {status2}") + + except Exception as e: + log(f" Failed to create PR for {item_type} #{number}: {e}") + try: + run("git checkout main 2>/dev/null || true") + run(f"git branch -D {branch} 2>/dev/null || true") + except: + pass + continue + + return created + +# ── Phase 6: Write report ───────────────────────────────────────────────────── + +def phase6_report(ci_results, issues, prs, created): + log("=== Phase 6: Write report ===") + ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + ci_status = {k: "✅ PASS" if v else "❌ FAIL" for k, v in ci_results.items()} + + lines = [ + f"# TermUI Cron Run Report — {ts}", + "", + f"## Run Info", + f"- Fork: {FORK_OWNER}/{REPO}", + f"- Upstream: {UPSTREAM_OWNER}/{REPO}", + f"- Branch: {HEAD_BRANCH}", + f"- Token: `${{GH_TOKEN}}` (redacted)", + "", + f"## CI Gate", + f"- Build: {ci_status.get('build', '❌ FAIL')}", + f"- Typecheck: {ci_status.get('typecheck', '❌ FAIL')}", + f"- Test: {ci_status.get('test', '❌ FAIL')}", + "", + f"## Upstream Candidates Found", + f"- Issues: {len(issues)} candidate issues (open, unassigned, <5 comments, bug/enhancement/feature label)", + f"- PRs: {len(prs)} candidate PRs (open, mergeable, <5 comments, not bot)", + "", + f"## PRs Created", + ] + if created: + for c in created: + lines.append(f"- {c['type'].upper()} #{c['number']} → Fork PR #{c.get('fork_pr','?')}: {c.get('fork_url','?')}") + else: + lines.append("- None") + + lines += [ + "", + f"## Notes", + f"- Fork issues are **disabled** on tmdeveloper007/TermUI (HTTP 410) — issue creation skipped", + f"- Upstream PR creation blocked at API level (account-level restriction) — fork-only PRs created", + f"- Token redacted from all report files; use `${{GH_TOKEN}}` placeholder", + ] + + report = "\n".join(lines) + "\n" + os.makedirs(os.path.dirname(REPORT_PATH), exist_ok=True) + with open(REPORT_PATH, "w") as f: + f.write(report) + log(f"Report written to {REPORT_PATH}") + return report + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main(): + log(f"TermUI cron start — {datetime.now(timezone.utc).isoformat()}") + log(f"GH_TOKEN set: {bool(GH_TOKEN)}") + + try: + phase0_git_setup() + phase1_sync() + phase2_deps() + ci_results, all_passed = phase3_ci() + + if not all_passed: + log("CI FAILED — skipping PR creation") + issues, prs = [], [] + created = [] + else: + issues, prs = phase4_fetch() + created = phase5_prs(issues, prs) + + report = phase6_report(ci_results, issues, prs, created) + print("\n" + report) + + log("DONE") + + except Exception as e: + log(f"FATAL: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/packages/data/src/services.ts b/packages/data/src/services.ts index b9d7eb53c..e3fa9a718 100644 --- a/packages/data/src/services.ts +++ b/packages/data/src/services.ts @@ -64,8 +64,9 @@ function parseSystemdShow(output: string, serviceName: string): ParsedSystemdSer return { name: serviceName, description, activeState, subState, pid, nRestarts, activeEnterTimestamp }; } -function getSystemdServices(serviceNames: string[]): ServiceInfo[] { +function getSystemdServices(serviceNames: string[]): [ServiceInfo[], string[]] { const results: ServiceInfo[] = []; + const failedNames: string[] = []; for (const name of serviceNames) { try { const output = execFileSync('systemctl', ['show', name], { @@ -73,7 +74,10 @@ function getSystemdServices(serviceNames: string[]): ServiceInfo[] { timeout: 3000, }); const parsed = parseSystemdShow(output, name); - if (!parsed) continue; + if (!parsed) { + failedNames.push(name); + continue; + } const active = parsed.activeState === 'active'; const uptimeSeconds = active && parsed.activeEnterTimestamp > 0 @@ -109,10 +113,10 @@ function getSystemdServices(serviceNames: string[]): ServiceInfo[] { description: parsed.description, }); } catch { - // systemctl not available or service not found — try fallback + failedNames.push(name); } } - return results; + return [results, failedNames]; } interface Pm2Process { @@ -237,8 +241,24 @@ export const services = { if (os.platform() === 'linux') { try { execFileSync('systemctl', ['--version'], { encoding: 'utf-8', timeout: 1000 }); - const svcs = getSystemdServices(serviceNames); - if (svcs.length > 0) return svcs; + const [svcs, failedNames] = getSystemdServices(serviceNames); + if (svcs.length > 0) { + // Pass unresolved names to PM2 / process fallback + if (failedNames.length > 0) { + const pm2Svcs = getPm2Services(failedNames); + const pm2Names = new Set(pm2Svcs.map(s => s.name)); + const stillMissing = failedNames.filter(n => !pm2Names.has(n)); + return [...svcs, ...pm2Svcs, ...getProcessFallback(stillMissing)]; + } + return svcs; + } + // systemctl available but no services resolved — pass all names to fallback + if (failedNames.length > 0) { + const pm2Svcs = getPm2Services(failedNames); + const pm2Names = new Set(pm2Svcs.map(s => s.name)); + const stillMissing = failedNames.filter(n => !pm2Names.has(n)); + return [...pm2Svcs, ...getProcessFallback(stillMissing)]; + } } catch { // systemctl not available — continue to PM2 }