feat(google-calendar): wire freebusy, align tools with API v3, add calendar + sharing tools #35
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: companion-pr-check | |
| # Soft, NON-BLOCKING warning: when a PR targeting staging/main declares a | |
| # cross-repo "Companion:" PR, surface whether that companion is merged yet, so | |
| # copilot and sim stay in lockstep (a change in one often needs the other). | |
| # | |
| # Declare in a PR description (repeatable; shorthand OR full URL both parse): | |
| # Companion: simstudioai/sim#1234 | |
| # Companion: https://github.com/simstudioai/sim/pull/1234 | |
| # | |
| # Requires a CROSS_REPO_TOKEN secret (fine-grained PAT with pull-requests:read on | |
| # BOTH repos) to read the other repo's PR state. Without it the check still | |
| # surfaces the declared link but reports "couldn't verify". | |
| on: | |
| pull_request: | |
| types: [opened, edited, reopened, synchronize] | |
| branches: [staging, main] | |
| schedule: | |
| # Refresh open staging/main PRs in case the companion merges AFTER this PR was | |
| # opened. CAVEAT: GitHub runs scheduled workflows ONLY from the DEFAULT branch's | |
| # copy of this file — so this auto-refresh activates once the workflow lands on | |
| # the default branch (via the normal promotion), not before. The pull_request | |
| # triggers below always work; re-editing the PR re-runs the check meanwhile. | |
| - cron: '*/30 * * * *' | |
| workflow_dispatch: {} | |
| permissions: | |
| pull-requests: write | |
| issues: write | |
| contents: read | |
| jobs: | |
| companion: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/github-script@v7 | |
| env: | |
| CROSS_REPO_TOKEN: ${{ secrets.CROSS_REPO_TOKEN }} | |
| with: | |
| script: | | |
| const STICKY = '<!-- companion-pr-check -->'; | |
| // Two ways to declare a companion (either works; both feed this warning): | |
| // 1) a trailer anywhere: Companion: owner/repo#N (or a full PR URL) | |
| // 2) refs in a task list under a "## Companion..." heading — which ALSO | |
| // renders a native live badge + progress bar on the PR (the "both" path): | |
| // ## Companion PRs | |
| // - [ ] owner/repo#N | |
| const TRAILER = /Companion:\s*(?:https?:\/\/github\.com\/)?([\w.-]+)\/([\w.-]+)(?:\/pull\/|#)(\d+)/gi; | |
| const REF = /(?:https?:\/\/github\.com\/)?([\w.-]+)\/([\w.-]+)(?:\/pull\/|#)(\d+)/g; | |
| const { owner, repo } = context.repo; | |
| const crossToken = process.env.CROSS_REPO_TOKEN; | |
| // Read the OTHER repo's PR via a plain REST fetch with the PAT in the | |
| // header — keeps the PAT strictly READ-ONLY and avoids re-instantiating | |
| // Octokit inside github-script (which can't require('@actions/github')). | |
| // Commenting/labeling uses the default GITHUB_TOKEN via `github`. | |
| async function crossGetPR(c) { | |
| const res = await fetch(`https://api.github.com/repos/${c.owner}/${c.repo}/pulls/${c.number}`, { | |
| headers: { | |
| authorization: `Bearer ${crossToken}`, | |
| accept: 'application/vnd.github+json', | |
| 'x-github-api-version': '2022-11-28', | |
| 'user-agent': 'companion-pr-check', | |
| }, | |
| }); | |
| if (!res.ok) { const e = new Error(`HTTP ${res.status}`); e.status = res.status; throw e; } | |
| return res.json(); | |
| } | |
| function parseCompanions(body) { | |
| body = body || ''; | |
| const out = []; | |
| const seen = new Set(); | |
| const add = (o, r, n) => { | |
| const ref = `${o}/${r}#${n}`; | |
| if (seen.has(ref)) return; | |
| seen.add(ref); | |
| out.push({ owner: o, repo: r, number: Number(n), ref }); | |
| }; | |
| // (1) "Companion:" trailers anywhere in the body. | |
| let m; | |
| TRAILER.lastIndex = 0; | |
| while ((m = TRAILER.exec(body)) !== null) add(m[1], m[2], m[3]); | |
| // (2) refs in a task list under a "## Companion..." heading, until the next heading. | |
| let inSection = false; | |
| for (const line of body.split(/\r?\n/)) { | |
| if (/^#{1,6}\s/.test(line)) { inSection = /^#{1,6}\s*companion/i.test(line); continue; } | |
| if (!inSection) continue; | |
| let mm; | |
| REF.lastIndex = 0; | |
| while ((mm = REF.exec(line)) !== null) add(mm[1], mm[2], mm[3]); | |
| } | |
| return out; | |
| } | |
| async function findSticky(prNumber) { | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner, repo, issue_number: prNumber, per_page: 100, | |
| }); | |
| return comments.find((c) => (c.body || '').includes(STICKY)); | |
| } | |
| async function upsert(prNumber, body) { | |
| const ex = await findSticky(prNumber); | |
| if (ex) await github.rest.issues.updateComment({ owner, repo, comment_id: ex.id, body }); | |
| else await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body }); | |
| } | |
| async function clear(prNumber) { | |
| const ex = await findSticky(prNumber); | |
| if (ex) await github.rest.issues.deleteComment({ owner, repo, comment_id: ex.id }); | |
| // Drop the label too, so a PR edited to remove all companions doesn't | |
| // keep a stale has-companion badge. 404 if not present → ignore. | |
| try { await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: 'has-companion' }); } catch {} | |
| } | |
| async function ensureLabel() { | |
| try { await github.rest.issues.getLabel({ owner, repo, name: 'has-companion' }); } | |
| catch { | |
| try { | |
| await github.rest.issues.createLabel({ | |
| owner, repo, name: 'has-companion', color: '5319e7', | |
| description: 'Has a cross-repo companion PR (see companion-pr-check)', | |
| }); | |
| } catch {} | |
| } | |
| } | |
| // staging PRs are a single feature → just this PR's body ("the one"). | |
| // main (prod) release PRs bundle MANY feature PRs → aggregate the | |
| // companions declared on each squashed feature PR too, so "does any | |
| // commit in this release have a companion?" is answered. | |
| async function collectCompanions(pr) { | |
| const companions = parseCompanions(pr.body); | |
| const seen = new Set(companions.map((c) => c.ref)); | |
| if (pr.base.ref === 'main') { | |
| let commits = []; | |
| try { | |
| commits = await github.paginate(github.rest.pulls.listCommits, { | |
| owner, repo, pull_number: pr.number, per_page: 100, | |
| }); | |
| } catch {} | |
| const featurePRs = new Set(); | |
| const SQUASH = /\(#(\d+)\)/g; // squash-merge refs like "...(#306)" | |
| for (const c of commits) { | |
| const msg = (c.commit && c.commit.message) || ''; | |
| let m; | |
| SQUASH.lastIndex = 0; | |
| while ((m = SQUASH.exec(msg)) !== null) featurePRs.add(Number(m[1])); | |
| } | |
| for (const n of featurePRs) { | |
| if (n === pr.number) continue; | |
| try { | |
| const { data: fpr } = await github.rest.pulls.get({ owner, repo, pull_number: n }); | |
| for (const c of parseCompanions(fpr.body)) { | |
| if (!seen.has(c.ref)) { seen.add(c.ref); companions.push(c); } | |
| } | |
| } catch {} | |
| } | |
| } | |
| return companions; | |
| } | |
| async function checkPR(pr) { | |
| const companions = await collectCompanions(pr); | |
| if (companions.length === 0) { await clear(pr.number); return; } | |
| await ensureLabel(); | |
| try { await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: ['has-companion'] }); } catch {} | |
| const base = pr.base.ref; | |
| const lines = []; | |
| let warn = false; | |
| for (const c of companions) { | |
| if (!crossToken) { | |
| lines.push(`- ❓ \`${c.ref}\` — set the **CROSS_REPO_TOKEN** secret to verify merge status`); | |
| warn = true; | |
| continue; | |
| } | |
| try { | |
| const cp = await crossGetPR(c); | |
| const title = (cp.title || '').slice(0, 80); | |
| if (cp.merged) { | |
| const tierOk = cp.base.ref === base; | |
| lines.push(`- ${tierOk ? '✅' : '⚠️'} [\`${c.ref}\`](${cp.html_url}) — merged into \`${cp.base.ref}\`${tierOk ? '' : ` (this PR targets \`${base}\`)`} — ${title}`); | |
| if (!tierOk) warn = true; | |
| } else { | |
| lines.push(`- ❌ [\`${c.ref}\`](${cp.html_url}) — **${String(cp.state).toUpperCase()}, not merged** (targets \`${cp.base.ref}\`) — ${title}`); | |
| warn = true; | |
| } | |
| } catch (e) { | |
| lines.push(`- ❓ \`${c.ref}\` — couldn't read (${e.status || e.message}); check CROSS_REPO_TOKEN scope`); | |
| warn = true; | |
| } | |
| } | |
| const heading = warn ? '## ⚠️ Cross-repo companion check' : '## ✅ Cross-repo companion check'; | |
| const scope = base === 'main' ? ' (aggregated across the feature PRs in this release)' : ''; | |
| const note = warn | |
| ? `One or more companion PRs aren't merged into \`${base}\` yet${scope}. Merging this without them will leave copilot and sim out of sync — merge them in lockstep.` | |
| : `All declared companion PRs are merged into \`${base}\`${scope}.`; | |
| await upsert(pr.number, `${STICKY}\n${heading}\n\n${note}\n\n${lines.join('\n')}`); | |
| } | |
| if (context.eventName === 'pull_request') { | |
| await checkPR(context.payload.pull_request); | |
| } else { | |
| for (const b of ['staging', 'main']) { | |
| const prs = await github.paginate(github.rest.pulls.list, { owner, repo, base: b, state: 'open', per_page: 100 }); | |
| for (const pr of prs) await checkPR(pr); | |
| } | |
| } |