|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * Polls upstream projects this plugin depends on, diffs against |
| 4 | + * .github/upstream-state.json, and (if anything changed) emits: |
| 5 | + * - an updated state file |
| 6 | + * - upstream-issue-body.md — body for an issue to assign to copilot-swe-agent |
| 7 | + * - upstream-issue-title.txt — issue title |
| 8 | + * - has_changes=true on $GITHUB_OUTPUT |
| 9 | + * |
| 10 | + * Cursor's CLI isn't on GitHub and its changelog is JS-rendered — too brittle to |
| 11 | + * diff reliably from a workflow. We list it as a manual-reference source in the |
| 12 | + * issue body and let Copilot do the actual research. |
| 13 | + */ |
| 14 | + |
| 15 | +import fs from "node:fs"; |
| 16 | +import path from "node:path"; |
| 17 | +import process from "node:process"; |
| 18 | + |
| 19 | +const STATE_PATH = path.join(".github", "upstream-state.json"); |
| 20 | +const ISSUE_BODY_PATH = "upstream-issue-body.md"; |
| 21 | +const ISSUE_TITLE_PATH = "upstream-issue-title.txt"; |
| 22 | + |
| 23 | +const SOURCES = [ |
| 24 | + { |
| 25 | + id: "acp-spec", |
| 26 | + label: "ACP spec", |
| 27 | + repo: "agentclientprotocol/agent-client-protocol", |
| 28 | + relevance: "Defines the JSON-RPC methods the plugin's `acp-client.mjs` implements. Spec changes can introduce new methods we should handle (e.g. `terminal/*`, `session/request_permission`, `cursor/ask_question`)." |
| 29 | + }, |
| 30 | + { |
| 31 | + id: "gemini-cli", |
| 32 | + label: "Gemini CLI", |
| 33 | + repo: "google-gemini/gemini-cli", |
| 34 | + relevance: "Drives `plugins/multi/scripts/lib/adapters/gemini.mjs`. Watch for changes to ACP handshake, model alias resolution, MCP support, or new approval modes." |
| 35 | + }, |
| 36 | + { |
| 37 | + id: "codex-cli", |
| 38 | + label: "Codex CLI (App Server Protocol)", |
| 39 | + repo: "openai/codex", |
| 40 | + relevance: "Drives `plugins/multi/scripts/lib/adapters/codex.mjs` and `app-server.mjs`. Watch for sandbox mode changes, new approval policies, or app-server protocol updates." |
| 41 | + }, |
| 42 | + { |
| 43 | + id: "copilot-cli", |
| 44 | + label: "GitHub Copilot CLI", |
| 45 | + repo: "github/copilot-cli", |
| 46 | + relevance: "Drives `plugins/multi/scripts/lib/adapters/copilot.mjs`. Watch for ACP changes, slash-command additions/removals, or auth flow changes." |
| 47 | + }, |
| 48 | + { |
| 49 | + id: "qwen-code", |
| 50 | + label: "Qwen Code", |
| 51 | + repo: "QwenLM/qwen-code", |
| 52 | + relevance: "Drives `plugins/multi/scripts/lib/adapters/qwen.mjs`. Watch for ACP support changes (the `--acp` flag graduated from `--experimental-acp` recently)." |
| 53 | + } |
| 54 | +]; |
| 55 | + |
| 56 | +// Manual-reference sources the workflow can't reliably auto-diff. Copilot is |
| 57 | +// asked to research these inside the issue. |
| 58 | +const MANUAL_SOURCES = [ |
| 59 | + { |
| 60 | + label: "Cursor agent CLI", |
| 61 | + changelog: "https://cursor.com/changelog", |
| 62 | + forum: "https://forum.cursor.com/c/bug-report/6", |
| 63 | + relevance: "Drives `plugins/multi/scripts/lib/adapters/cursor.mjs`. The plugin currently works around the 2026.04.17-787b533 ACP regression (see `maybeWarnAboutCursorVersion` and `ensureCursorAllowlist`). When Cursor ships a fix, both workarounds can likely be simplified or removed." |
| 64 | + } |
| 65 | +]; |
| 66 | + |
| 67 | +const PLUGIN_FILES_OF_INTEREST = [ |
| 68 | + "plugins/multi/scripts/lib/acp-client.mjs (shared ACP JSON-RPC client; `buildAutoApproveRequestHandler`)", |
| 69 | + "plugins/multi/scripts/lib/acp-terminals.mjs (client-side terminal services)", |
| 70 | + "plugins/multi/scripts/lib/mcp-servers.mjs (MCP wiring for ACP `session/new`)", |
| 71 | + "plugins/multi/scripts/lib/adapters/{codex,gemini,cursor,copilot,qwen}.mjs (per-CLI adapters)", |
| 72 | + "plugins/multi/scripts/multi-cli-companion.mjs (companion runtime + dispatch)" |
| 73 | +]; |
| 74 | + |
| 75 | +function readState() { |
| 76 | + try { |
| 77 | + return JSON.parse(fs.readFileSync(STATE_PATH, "utf8")); |
| 78 | + } catch { |
| 79 | + return {}; |
| 80 | + } |
| 81 | +} |
| 82 | + |
| 83 | +async function fetchLatestRelease(repo) { |
| 84 | + const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, { |
| 85 | + headers: { |
| 86 | + "User-Agent": "cc-multi-cli-plugin-upstream-watch", |
| 87 | + "Accept": "application/vnd.github+json", |
| 88 | + ...(process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}) |
| 89 | + } |
| 90 | + }); |
| 91 | + if (res.status === 404) return null; // repo has no releases yet |
| 92 | + if (!res.ok) throw new Error(`GitHub API ${res.status} for ${repo}`); |
| 93 | + const data = await res.json(); |
| 94 | + return { |
| 95 | + tag: data.tag_name, |
| 96 | + name: data.name, |
| 97 | + publishedAt: data.published_at, |
| 98 | + htmlUrl: data.html_url, |
| 99 | + body: typeof data.body === "string" ? data.body : "" |
| 100 | + }; |
| 101 | +} |
| 102 | + |
| 103 | +function trimReleaseNotes(body, maxLines = 30) { |
| 104 | + if (!body) return "_(no release notes provided)_"; |
| 105 | + const lines = body.replace(/\r/g, "").split("\n").filter(Boolean); |
| 106 | + if (lines.length <= maxLines) return lines.join("\n"); |
| 107 | + return lines.slice(0, maxLines).join("\n") + "\n\n_…(release notes truncated; click the link above for the full text)_"; |
| 108 | +} |
| 109 | + |
| 110 | +function appendOutput(key, value) { |
| 111 | + if (!process.env.GITHUB_OUTPUT) return; |
| 112 | + fs.appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value}\n`); |
| 113 | +} |
| 114 | + |
| 115 | +function renderIssueBody(changes) { |
| 116 | + const today = new Date().toISOString().slice(0, 10); |
| 117 | + const sections = changes.map((c) => `### ${c.source.label} — ${c.previous ?? "(none)"} → \`${c.latest.tag}\` |
| 118 | +
|
| 119 | +- **Release**: [${c.latest.name ?? c.latest.tag}](${c.latest.htmlUrl}) (published ${c.latest.publishedAt?.slice(0, 10) ?? "?"}) |
| 120 | +- **Why this matters for the plugin**: ${c.source.relevance} |
| 121 | +
|
| 122 | +<details><summary>Release notes excerpt</summary> |
| 123 | +
|
| 124 | +${trimReleaseNotes(c.latest.body)} |
| 125 | +
|
| 126 | +</details>`); |
| 127 | + |
| 128 | + const manualSection = MANUAL_SOURCES.map((s) => `- **${s.label}** — changelog: ${s.changelog} · forum (bug reports): ${s.forum} |
| 129 | + - ${s.relevance}`).join("\n"); |
| 130 | + |
| 131 | + const filesSection = PLUGIN_FILES_OF_INTEREST.map((f) => `- \`${f}\``).join("\n"); |
| 132 | + |
| 133 | + return `_This issue was opened automatically by [\`upstream-watch.yml\`](../actions/workflows/upstream-watch.yml) on ${today} because at least one upstream project this plugin depends on shipped a new release._ |
| 134 | +
|
| 135 | +## Detected changes |
| 136 | +
|
| 137 | +${sections.join("\n\n")} |
| 138 | +
|
| 139 | +## Also worth checking (manual — Copilot, please research these too) |
| 140 | +
|
| 141 | +${manualSection} |
| 142 | +
|
| 143 | +## Plugin files most likely to need updates |
| 144 | +
|
| 145 | +${filesSection} |
| 146 | +
|
| 147 | +## What I'd like you to do, @copilot |
| 148 | +
|
| 149 | +1. **Read the linked release notes** for each detected change above, plus the manual-reference changelogs. |
| 150 | +2. **Compare what changed against the relevant adapter / shared code** in this repo. Look specifically for: |
| 151 | + - New ACP methods we should handle in \`acp-client.mjs\`'s \`buildAutoApproveRequestHandler\` |
| 152 | + - Renamed / deprecated CLI flags or model IDs hardcoded in any adapter |
| 153 | + - New CLI capabilities that obsolete a workaround we currently ship (e.g., the Cursor 2026.04.17 regression workaround in \`cursor.mjs\` — check if a newer Cursor release fixes it, and if so, propose removing \`maybeWarnAboutCursorVersion\` and the allowlist-injection or scoping it tighter) |
| 154 | + - Breaking changes that would silently break the plugin |
| 155 | +3. **For each change that warrants action**, open a focused PR against \`master\` with the minimal fix. Reference the upstream release / commit / forum thread in the PR description. |
| 156 | +4. **If a detected change does NOT need any plugin update**, reply on this issue with a short note saying "no plugin updates needed for X — reason: …" and close it. |
| 157 | +5. **If something is ambiguous** (you can't tell from release notes whether the plugin is affected), ask in a comment rather than guessing. |
| 158 | +
|
| 159 | +You may use \`ACP_TRACE=1\` and the rest of the diagnostic patterns documented in \`plugins/multi/skills/customize/SKILL.md\` if you want to verify behavior empirically. |
| 160 | +
|
| 161 | +--- |
| 162 | +
|
| 163 | +_State tracked in \`.github/upstream-state.json\` — bumped by this same workflow. If you want to suppress a noisy upstream from this watch, edit \`.github/scripts/upstream-watch.mjs\`._ |
| 164 | +`; |
| 165 | +} |
| 166 | + |
| 167 | +async function main() { |
| 168 | + const state = readState(); |
| 169 | + const newState = { ...state }; |
| 170 | + const changes = []; |
| 171 | + |
| 172 | + for (const src of SOURCES) { |
| 173 | + try { |
| 174 | + const latest = await fetchLatestRelease(src.repo); |
| 175 | + if (!latest || !latest.tag) { |
| 176 | + console.error(`[${src.id}] no latest release found`); |
| 177 | + continue; |
| 178 | + } |
| 179 | + const previousTag = state[src.id]?.latest; |
| 180 | + if (previousTag !== latest.tag) { |
| 181 | + console.log(`[${src.id}] CHANGED: ${previousTag ?? "(none)"} -> ${latest.tag}`); |
| 182 | + changes.push({ source: src, previous: previousTag, latest }); |
| 183 | + // Only update state when the tag actually changed. Avoids weekly |
| 184 | + // commit noise from pure `checkedAt` bumps. Look at the Actions tab |
| 185 | + // for last-run-time visibility instead. |
| 186 | + newState[src.id] = { latest: latest.tag, checkedAt: new Date().toISOString() }; |
| 187 | + } else { |
| 188 | + console.log(`[${src.id}] unchanged at ${latest.tag}`); |
| 189 | + // Preserve existing entry verbatim — don't touch `checkedAt`. |
| 190 | + } |
| 191 | + } catch (err) { |
| 192 | + console.error(`[${src.id}] failed:`, err.message); |
| 193 | + // Preserve previous state for this source; don't drop it. |
| 194 | + if (state[src.id]) newState[src.id] = state[src.id]; |
| 195 | + } |
| 196 | + } |
| 197 | + |
| 198 | + // Preserve the leading _comment field on rewrite. |
| 199 | + if (state._comment) newState._comment = state._comment; |
| 200 | + |
| 201 | + fs.writeFileSync(STATE_PATH, JSON.stringify(newState, null, 2) + "\n"); |
| 202 | + console.log(`State written: ${STATE_PATH}`); |
| 203 | + |
| 204 | + if (changes.length === 0) { |
| 205 | + console.log("No upstream changes detected."); |
| 206 | + appendOutput("has_changes", "false"); |
| 207 | + return; |
| 208 | + } |
| 209 | + |
| 210 | + const labels = changes.map((c) => c.source.label).join(", "); |
| 211 | + const title = `Upstream changes detected: ${labels}`; |
| 212 | + fs.writeFileSync(ISSUE_TITLE_PATH, title); |
| 213 | + fs.writeFileSync(ISSUE_BODY_PATH, renderIssueBody(changes)); |
| 214 | + console.log(`Issue title: ${title}`); |
| 215 | + console.log(`Issue body written to ${ISSUE_BODY_PATH}`); |
| 216 | + appendOutput("has_changes", "true"); |
| 217 | + appendOutput("issue_title", title); |
| 218 | +} |
| 219 | + |
| 220 | +main().catch((err) => { |
| 221 | + console.error("upstream-watch failed:", err); |
| 222 | + process.exit(1); |
| 223 | +}); |
0 commit comments