From 6d988669131e50a16d01aca7fb106dcf05b195da Mon Sep 17 00:00:00 2001 From: Tania Charchian Date: Fri, 10 Jul 2026 12:01:07 -0700 Subject: [PATCH 1/4] ci: add Linear <-> GitHub PR sync automation Adds a custom GitHub Action that mirrors every pull request (including external fork PRs) into Linear and keeps issues in sync with the PR lifecycle: - opened: parent issue in the GON team Triage, first-time-contributor label, per-reviewer "review needed" sub-issues, and auto-requests the reviewers on the PR (author is excluded / swapped) - milestoned: moves parent + sub-issues into the "Upgrade " release project (created if missing) - review approved: reviewer's sub-issue -> Done - merged: parent -> "Merged. Ready for testing"; each review sub-issue -> Done only if that reviewer approved, else "Not done"; creates a Q&A "Testing" sub-issue (Todo, assigned to QA owner) - closed by a reviewer: parent Done, their sub Done, other Cancelled - closed by anyone else: parent Cancelled, review subs "Not done" - merged/approved without a milestone: parked in an "unsorted" project Config via LINEAR_API_KEY secret + repository variables; identities, states, projects and labels are resolved by name at runtime. Co-authored-by: Cursor --- .github/scripts/linear-pr-sync/README.md | 93 +++ .github/scripts/linear-pr-sync/package.json | 14 + .github/scripts/linear-pr-sync/sync.mjs | 805 ++++++++++++++++++++ .github/workflows/linear-pr-sync.yml | 109 +++ docs/linear-github-sync.md | 58 ++ 5 files changed, 1079 insertions(+) create mode 100644 .github/scripts/linear-pr-sync/README.md create mode 100644 .github/scripts/linear-pr-sync/package.json create mode 100644 .github/scripts/linear-pr-sync/sync.mjs create mode 100644 .github/workflows/linear-pr-sync.yml create mode 100644 docs/linear-github-sync.md diff --git a/.github/scripts/linear-pr-sync/README.md b/.github/scripts/linear-pr-sync/README.md new file mode 100644 index 0000000000..ffa1dae096 --- /dev/null +++ b/.github/scripts/linear-pr-sync/README.md @@ -0,0 +1,93 @@ +# Linear ↔ GitHub PR sync + +Mirrors every incoming pull request (including PRs from external forks) into Linear +and keeps the Linear issues in sync with the PR lifecycle. + +This is a **custom** integration on top of the Linear API, not the native Linear GitHub +integration. The native integration cannot auto-create an issue per external PR, detect +first-time contributors, build the parent/sub-issue structure, or apply the reviewer/close +logic below — hence this Action. + +## What it does + +| GitHub event | Result in Linear | +|---|---| +| PR **opened** / **reopened** (no milestone) | Parent issue created in the **GON** team **Triage** (no project). First-time contributor → label added. One **"`` — review needed"** sub-issue (state **Backlog**) per reviewer. The reviewers are also **requested on the GitHub PR** itself. | +| PR **opened** with a milestone already set | Same, but parent + sub-issues go straight into the release project (named after the milestone), not Triage. | +| PR **milestoned** (release milestone added) | Parent (and its sub-issues) move out of Triage into the project named after the milestone (created if missing). | +| PR **demilestoned** | Parent moves back to the GON team / Triage. | +| PR **review submitted = approved** by a reviewer | That reviewer's review sub-issue → **Done**. If the PR has no milestone, the parent is parked in the **unsorted** project so approved-but-unassigned work is visible. | +| PR **merged** | Parent → **Merged. Ready for testing**. Each review sub-issue → **Done** *only if that reviewer actually approved on GitHub*, otherwise → **Not done**. A **"`<title>` — Testing"** sub-issue is created in the **Q&A** team (state **Todo**), assigned to the QA owner, reviewers subscribed. If there was no milestone, the parent is first moved into the **unsorted** project (full pipeline still runs). | +| PR **closed** by a reviewer (Dima / Gabriel) without merge | Parent → **Done**, that reviewer's sub-issue → **Done**, the other sub-issue → **Cancelled**. | +| PR **closed** by anyone else without merge | Parent → **Cancelled**, review sub-issues → **Not done**. No Q&A testing sub-issue is created. | + +**Reviewer assignment rule:** normally a sub-issue is created for (and a GitHub review is +requested from) each reviewer. If the PR author *is* one of the reviewers, only the *other* +reviewer(s) apply (e.g. a PR by Dima is reviewed only by Gabriel, and vice versa). + +**Private comments:** internal comments you write on the Linear issue stay in Linear. Nothing +is pushed to GitHub unless explicitly added to this script, so your triage discussion is not +visible to external contributors. + +## Setup + +### 1. Create a Linear API key +Linear → Settings → API → Personal API keys (or an OAuth app token for a service account). +Add it as a **repository secret** named `LINEAR_API_KEY`. + +> Prefer a dedicated service-account user in Linear so issues/comments aren't attributed to a person. + +### 2. Add repository Variables +Settings → Secrets and variables → Actions → **Variables**: + +| Variable | Required | Value for this workspace | Notes | +|---|---|---|---| +| `LINEAR_TEAM_KEY` | ✅ | `GON` | Team "Gonka-core"; the key on issue ids like `GON-123`. | +| `LINEAR_REVIEWERS` | ✅ | see below | Maps GitHub logins to Linear users. | +| `LINEAR_MILESTONE_PROJECT_PREFIX` | – | `Upgrade ` | GitHub milestone `v0.2.15` → project `Upgrade v0.2.15`. Default already `Upgrade `. | +| `LINEAR_UNSORTED_PROJECT_NAME` | – | `Merged — no milestone (to sort)` | Bucket for PRs merged/approved without a milestone. Created if missing. | +| `LINEAR_CORE_PROJECT_NAME` | – | *(leave empty)* | The "Gonka core" folder is the GON team; un-milestoned PRs stay in Triage with no project. | +| `LINEAR_FIRST_CONTRIBUTOR_LABEL` | – | `first-time contributor` | Created if missing. (Existing alternatives: `contributor`, `Community-driven`.) | +| `LINEAR_BACKLOG_STATE_NAME` | – | `Backlog` | Default. | +| `LINEAR_DONE_STATE_NAME` | – | `Done` | Default. | +| `LINEAR_CANCELLED_STATE_NAME` | – | `Canceled` | Note the single-l spelling in this workspace. | +| `LINEAR_NOT_DONE_STATE_NAME` | – | `Not done` | Review sub-issue state when the reviewer never approved. | +| `LINEAR_RELEASE_START_STATE_NAME` | – | `Backlog` | State the parent gets when a milestone moves it into a release project. | +| `LINEAR_MERGED_STATE_NAME` | – | `Merged. Ready for testing` | State the parent gets on merge. | +| `LINEAR_QA_TEAM_KEY` | – | `QA` | Team that owns testing sub-issues. | +| `LINEAR_QA_TODO_STATE_NAME` | – | `Todo` | State for the QA testing sub-issue. | +| `LINEAR_TESTING_SUFFIX` | – | `Testing` | Title suffix: `<title> — Testing`. | +| `LINEAR_QA_ASSIGNEE_EMAIL` | – | `maria.mitina@productscience.ai` | Assignee of the QA testing sub-issue. | +| `LINEAR_QA_SUBSCRIBER_EMAILS` | – | `dbogdan@engenious.io,maria.mitina@productscience.ai` | Comma-separated; subscribed/mentioned on the QA sub-issue. | +| `LINEAR_REQUEST_GITHUB_REVIEWERS` | – | `true` | Set `false` to stop requesting reviewers on the PR. | + +`LINEAR_REVIEWERS` is a JSON array (single line). `github` = GitHub login, `email` = the +person's Linear account email, `name` = display name for logs: + +```json +[{"github":"DimaOrekhovPS","email":"dima.orekhov@productscience.ai","name":"Dima Orekhov"},{"github":"GLiberman","email":"gabriel@productscience.ai","name":"Gabriel Liberman"}] +``` + +### 3. Done +The workflow at `.github/workflows/linear-pr-sync.yml` runs automatically. To re-sync a single +PR manually, use the workflow's **Run workflow** button and pass the PR number. + +## Notes & assumptions + +- Uses `pull_request_target` so secrets are available for fork PRs. It never checks out or runs + PR code — only reads metadata and calls Linear. Do not add a checkout of the PR head. +- Workflow states, projects, users and labels are resolved **by name** at runtime, so no Linear + IDs are hard-coded. Missing projects and the label are created automatically. +- The PR ↔ issue link is stored as a Linear **attachment** on the parent issue (the PR URL), + which is how later events (milestone/close) find the right issue. +- On merge the parent moves to **Merged. Ready for testing**. Each review sub-issue is set to + **Done** only when that reviewer's latest GitHub review is an *approval*; otherwise it becomes + **Not done**. Approvals are read from the GitHub reviews API (comment-only reviews are ignored). +- If a PR is merged or approved **without a milestone**, it's assumed the milestone was simply + forgotten: the parent is moved into the **unsorted** project (`LINEAR_UNSORTED_PROJECT_NAME`) + so you can see everything that landed as merged/approved but isn't assigned to a release, and + sort it manually. The rest of the pipeline (merged state, Q&A hand-off) still runs. +- Requesting a GitHub reviewer requires the person to be a repo collaborator; if not, GitHub + returns 422 and the script logs it and continues (the Linear sub-issue is still created). +- The QA sub-issue subscribes `LINEAR_QA_SUBSCRIBER_EMAILS` (so they are notified) and lists + them in the description. diff --git a/.github/scripts/linear-pr-sync/package.json b/.github/scripts/linear-pr-sync/package.json new file mode 100644 index 0000000000..662659ce4e --- /dev/null +++ b/.github/scripts/linear-pr-sync/package.json @@ -0,0 +1,14 @@ +{ + "name": "linear-pr-sync", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Syncs GitHub pull requests into Linear issues (parent + review sub-issues) with milestone/close automation.", + "main": "sync.mjs", + "scripts": { + "start": "node sync.mjs" + }, + "dependencies": { + "@linear/sdk": "^88.0.0" + } +} diff --git a/.github/scripts/linear-pr-sync/sync.mjs b/.github/scripts/linear-pr-sync/sync.mjs new file mode 100644 index 0000000000..6df8e1e8c5 --- /dev/null +++ b/.github/scripts/linear-pr-sync/sync.mjs @@ -0,0 +1,805 @@ +// Syncs GitHub pull requests into Linear. +// +// Behaviour (see .github/scripts/linear-pr-sync/README.md for the full spec): +// opened / reopened -> create parent issue + "review needed" sub-issues, +// and request the reviewers on the GitHub PR itself +// milestoned -> move parent (and children) out of Triage into the release project +// demilestoned -> move parent (and children) back to the core project / Triage +// closed (merged) -> parent -> "Merged. Ready for testing", review sub-issues -> Done, +// and (if the parent is in a release project) a Q&A testing sub-issue +// is created (state "Todo", assigned to the QA owner) +// closed (unmerged) -> if closed by a reviewer: their sub-issue -> Done, parent -> Done, +// other sub-issue -> Cancelled +// otherwise: parent -> Cancelled, review sub-issues -> Not done +// (no Q&A testing sub-issue is created) +// +// All identity/state resolution is done by NAME at runtime, so the only hard config +// is: the Linear team key and the reviewer mapping. + +import fs from "node:fs"; +import { LinearClient } from "@linear/sdk"; + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +const cfg = { + apiKey: requireEnv("LINEAR_API_KEY"), + teamKey: requireEnv("LINEAR_TEAM_KEY"), + // The "Gonka core" folder is the GON *team*, not a project. Leave empty so that + // PRs without a milestone stay in the team's Triage with no project attached. + // Set it only if you actually want a project assigned to un-milestoned PRs. + coreProjectName: process.env.CORE_PROJECT_NAME || "", + // GitHub milestone "v0.2.15" maps to Linear project "Upgrade v0.2.15". + milestoneProjectPrefix: + process.env.MILESTONE_PROJECT_PREFIX || "Upgrade ", + // Bucket for PRs that were merged/approved WITHOUT a milestone, so they can be + // sorted into the right release project manually. Created if missing. + unsortedProjectName: + process.env.UNSORTED_PROJECT_NAME || "Merged — no milestone (to sort)", + reviewers: JSON.parse(process.env.REVIEWERS || "[]"), + firstContributorLabel: + process.env.FIRST_CONTRIBUTOR_LABEL || "first-time contributor", + backlogStateName: process.env.BACKLOG_STATE_NAME || "Backlog", + doneStateName: process.env.DONE_STATE_NAME || "Done", + cancelledStateName: process.env.CANCELLED_STATE_NAME || "Canceled", + // Review sub-issue state when the reviewer did NOT approve on GitHub. + notDoneStateName: process.env.NOT_DONE_STATE_NAME || "Not done", + releaseStartStateName: process.env.RELEASE_START_STATE_NAME || "Backlog", + // On merge the parent moves to this state and a Q&A testing sub-issue is created. + mergedStateName: process.env.MERGED_STATE_NAME || "Merged. Ready for testing", + + // Q&A testing sub-issue (created when a milestoned PR is merged). + qaTeamKey: process.env.QA_TEAM_KEY || "QA", + qaTodoStateName: process.env.QA_TODO_STATE_NAME || "Todo", + testingSuffix: process.env.TESTING_SUFFIX || "Testing", + qaAssigneeEmail: + process.env.QA_ASSIGNEE_EMAIL || "maria.mitina@productscience.ai", + qaSubscriberEmails: ( + process.env.QA_SUBSCRIBER_EMAILS || + "dbogdan@engenious.io,maria.mitina@productscience.ai" + ) + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + + // Auto-request the reviewers on the GitHub PR (set to "false" to disable). + requestGithubReviewers: + (process.env.REQUEST_GITHUB_REVIEWERS || "true").toLowerCase() !== "false", +}; + +const milestoneProjectName = (milestone) => + `${cfg.milestoneProjectPrefix}${milestone}`; + +const client = new LinearClient({ apiKey: cfg.apiKey }); + +// Marker embedded in the parent issue description. Also used as an attachment URL, +// which is how we reliably find the issue again on later events. +const prMarker = (repo, number) => `github-pr:${repo}#${number}`; + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +async function main() { + const pr = await loadPullRequest(); + if (!pr) return; + + const eventName = process.env.GITHUB_EVENT_NAME || ""; + const action = process.env.GITHUB_EVENT_ACTION || "opened"; + console.log( + `Event: ${eventName || "pull_request"}/${action} for PR ${pr.repo}#${pr.number} ("${pr.title}")`, + ); + + if (eventName === "pull_request_review") { + await onReviewSubmitted(pr); + return; + } + + switch (action) { + case "opened": + case "reopened": + await onOpenedOrReopened(pr); + break; + case "milestoned": + await onMilestoned(pr); + break; + case "demilestoned": + await onDemilestoned(pr); + break; + case "closed": + await onClosed(pr); + break; + default: + console.log(`No handler for action "${action}", nothing to do.`); + } +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +async function onOpenedOrReopened(pr) { + if (cfg.requestGithubReviewers) await requestGithubReviewers(pr); + + const team = await getTeam(); + const states = await getStates(team); + + const existing = await findParentIssue(pr); + + // With a milestone -> the release project. Without -> optional core project (usually + // none: the issue just lives in the GON team's Triage). + const project = pr.milestone + ? await getOrCreateProject(milestoneProjectName(pr.milestone), team.id) + : await findCoreProject(team.id); + const projectId = project ? project.id : null; + + // No milestone -> Triage. With milestone -> the release project's start state. + const parentState = pr.milestone + ? states.releaseStart + : states.triage || states.backlog; + + if (existing) { + // Reopened / re-synced: revive the issue rather than creating a duplicate. + await client.updateIssue(existing.id, { + stateId: parentState.id, + projectId, + }); + const children = await getChildren(existing); + for (const child of children) { + await client.updateIssue(child.id, { + stateId: states.backlog.id, + projectId, + }); + } + console.log(`Revived existing parent issue ${existing.identifier}.`); + return; + } + + const labelIds = []; + if (pr.firstTimeContributor) { + const label = await getOrCreateLabel(cfg.firstContributorLabel, team.id); + labelIds.push(label.id); + } + + const parentRes = await client.createIssue({ + teamId: team.id, + projectId, + stateId: parentState.id, + title: pr.title, + description: parentDescription(pr), + labelIds, + }); + const parent = await parentRes.issue; + console.log(`Created parent issue ${parent.identifier}.`); + + // Attach the PR URL so we can look this issue up again later. + await client.createAttachment({ + issueId: parent.id, + url: pr.url, + title: `GitHub PR #${pr.number}`, + }); + + const reviewers = reviewersFor(pr.authorLogin); + for (const reviewer of reviewers) { + const user = await getUserByEmail(reviewer.email); + await client.createIssue({ + teamId: team.id, + projectId, + parentId: parent.id, + stateId: states.backlog.id, + title: `${pr.title} — review needed`, + description: `Review needed for ${pr.url}\n\n${prMarker(pr.repo, pr.number)}`, + assigneeId: user ? user.id : undefined, + }); + console.log( + `Created review sub-issue for ${reviewer.name}${user ? "" : " (unassigned: Linear user not found)"}.`, + ); + } +} + +async function onMilestoned(pr) { + if (!pr.milestone) return; + const parent = await findParentIssue(pr); + if (!parent) return warnNoParent(pr); + + const team = await getTeam(); + const states = await getStates(team); + const project = await getOrCreateProject( + milestoneProjectName(pr.milestone), + team.id, + ); + + await client.updateIssue(parent.id, { + projectId: project.id, + stateId: states.releaseStart.id, + }); + for (const child of await getChildren(parent)) { + await client.updateIssue(child.id, { projectId: project.id }); + } + console.log( + `Moved ${parent.identifier} to release project "${pr.milestone}".`, + ); +} + +async function onDemilestoned(pr) { + const parent = await findParentIssue(pr); + if (!parent) return warnNoParent(pr); + + const team = await getTeam(); + const states = await getStates(team); + const project = await findCoreProject(team.id); + const projectId = project ? project.id : null; + const backToState = states.triage || states.backlog; + + await client.updateIssue(parent.id, { + projectId, + stateId: backToState.id, + }); + for (const child of await getChildren(parent)) { + await client.updateIssue(child.id, { projectId }); + } + console.log(`Moved ${parent.identifier} back to Triage.`); +} + +async function onClosed(pr) { + const parent = await findParentIssue(pr); + if (!parent) return warnNoParent(pr); + + const team = await getTeam(); + const states = await getStates(team); + const children = await getChildren(parent); + + if (pr.merged) { + // Ensure the parent lives in a project. If it was merged without a milestone, + // drop it into the "unsorted" bucket so it can be triaged into a release later. + const projectId = await ensureParentProject(pr, parent, children, team, { + logLabel: "merged", + }); + + await client.updateIssue(parent.id, { stateId: states.merged.id }); + + // A review sub-issue is Done only if that reviewer actually approved on GitHub; + // otherwise it becomes "Not done". Leave any Q&A child alone. + const qaTeam = await getTeamByKey(cfg.qaTeamKey); + const approved = await fetchApprovedReviewerLogins(pr); + const reviewerByUser = await reviewerByUserId(); + for (const child of children) { + if ((await childTeamId(child)) === qaTeam.id) continue; + const assigneeId = await childAssigneeId(child); + const reviewer = assigneeId ? reviewerByUser.get(assigneeId) : null; + const didApprove = + reviewer && approved.has(reviewer.github.toLowerCase()); + await client.updateIssue(child.id, { + stateId: didApprove ? states.done.id : states.notDone.id, + }); + } + + await maybeCreateQaTestingIssue(pr, parent, children, qaTeam, projectId); + console.log( + `PR merged -> ${parent.identifier} set to "${cfg.mergedStateName}"; review sub-issues resolved by approval.`, + ); + return; + } + + const closerReviewer = reviewerByLogin(pr.closerLogin); + if (closerReviewer) { + // Closed by one of the reviewers -> treat as an accepted/handled close. + await client.updateIssue(parent.id, { stateId: states.done.id }); + const closerUser = await getUserByEmail(closerReviewer.email); + for (const child of children) { + const assigneeId = await childAssigneeId(child); + const isClosers = closerUser && assigneeId === closerUser.id; + await client.updateIssue(child.id, { + stateId: isClosers ? states.done.id : states.cancelled.id, + }); + } + console.log( + `PR closed by reviewer ${closerReviewer.name} -> parent Done, their sub-issue Done, others Cancelled.`, + ); + return; + } + + // Closed by someone who is not a reviewer (no merge): cancel the parent, and mark + // the review sub-issues as "Not done". No Q&A testing sub-issue is created (that only + // happens on merge). + await client.updateIssue(parent.id, { stateId: states.cancelled.id }); + for (const child of children) { + await client.updateIssue(child.id, { stateId: states.notDone.id }); + } + console.log( + `PR closed by ${pr.closerLogin || "unknown"} -> ${parent.identifier} Cancelled, review sub-issues Not done.`, + ); +} + +// Approval from a reviewer: mark their review sub-issue Done. If the PR has no +// milestone yet, park the parent in the "unsorted" project so we can see approved +// work that still needs to be assigned to a release. +async function onReviewSubmitted(pr) { + if (!pr.review || (pr.review.state || "").toLowerCase() !== "approved") { + console.log("Review is not an approval; nothing to do."); + return; + } + const reviewer = reviewerByLogin(pr.review.login); + if (!reviewer) { + console.log(`Approval by non-reviewer ${pr.review.login}; ignoring.`); + return; + } + + const parent = await findParentIssue(pr); + if (!parent) return warnNoParent(pr); + + const team = await getTeam(); + const states = await getStates(team); + const children = await getChildren(parent); + + const user = await getUserByEmail(reviewer.email); + if (user) { + for (const child of children) { + if ((await childAssigneeId(child)) === user.id) { + await client.updateIssue(child.id, { stateId: states.done.id }); + console.log(`Approved by ${reviewer.name} -> their review sub-issue Done.`); + } + } + } + + if (!pr.milestone) { + const current = await parent.project; + if (!current) { + const project = await getOrCreateProject( + cfg.unsortedProjectName, + team.id, + ); + await client.updateIssue(parent.id, { + projectId: project.id, + stateId: states.releaseStart.id, + }); + for (const child of children) { + await client.updateIssue(child.id, { projectId: project.id }); + } + console.log( + `Approved without milestone -> moved ${parent.identifier} to "${cfg.unsortedProjectName}".`, + ); + } + } +} + +// --------------------------------------------------------------------------- +// Reviewer logic +// --------------------------------------------------------------------------- + +// If the PR author is one of the reviewers, only the *other* reviewer(s) review it. +function reviewersFor(authorLogin) { + const isAuthor = (r) => + r.github && authorLogin && + r.github.toLowerCase() === authorLogin.toLowerCase(); + const withoutAuthor = cfg.reviewers.filter((r) => !isAuthor(r)); + return withoutAuthor.length ? withoutAuthor : cfg.reviewers; +} + +function reviewerByLogin(login) { + if (!login) return null; + return ( + cfg.reviewers.find( + (r) => r.github && r.github.toLowerCase() === login.toLowerCase(), + ) || null + ); +} + +// --------------------------------------------------------------------------- +// Linear resolvers (cached per run) +// --------------------------------------------------------------------------- + +const _teams = new Map(); +async function getTeamByKey(key) { + if (_teams.has(key)) return _teams.get(key); + const res = await client.teams({ filter: { key: { eq: key } } }); + if (!res.nodes.length) { + throw new Error(`Linear team with key "${key}" not found.`); + } + _teams.set(key, res.nodes[0]); + return res.nodes[0]; +} + +async function getTeam() { + return getTeamByKey(cfg.teamKey); +} + +const _teamStateNodes = new Map(); +async function teamStateNodes(team) { + if (_teamStateNodes.has(team.id)) return _teamStateNodes.get(team.id); + const res = await team.states(); + _teamStateNodes.set(team.id, res.nodes); + return res.nodes; +} + +// Find a workflow state by preferred names, then by fallback types. +function pickState(nodes, names, types) { + for (const name of names) { + const s = nodes.find((x) => x.name.toLowerCase() === name.toLowerCase()); + if (s) return s; + } + for (const type of types) { + const s = nodes.find((x) => x.type === type); + if (s) return s; + } + return null; +} + +let _states; +async function getStates(team) { + if (_states) return _states; + const nodes = await teamStateNodes(team); + + _states = { + triage: pickState(nodes, [], ["triage"]), + backlog: pickState(nodes, [cfg.backlogStateName], ["backlog"]), + done: pickState(nodes, [cfg.doneStateName], ["completed"]), + cancelled: pickState( + nodes, + [cfg.cancelledStateName, "canceled", "cancelled"], + ["canceled"], + ), + notDone: pickState(nodes, [cfg.notDoneStateName], ["canceled"]), + releaseStart: pickState( + nodes, + [cfg.releaseStartStateName], + ["backlog", "unstarted"], + ), + merged: pickState(nodes, [cfg.mergedStateName], ["started"]), + }; + + for (const [k, v] of Object.entries(_states)) { + if (!v && k !== "triage") { + throw new Error( + `Could not resolve Linear workflow state for "${k}". Check the *_STATE_NAME variables.`, + ); + } + } + return _states; +} + +// Resolve the "Todo" state in the Q&A team for new testing sub-issues. +async function getQaTodoState() { + const qaTeam = await getTeamByKey(cfg.qaTeamKey); + const nodes = await teamStateNodes(qaTeam); + const state = pickState( + nodes, + [cfg.qaTodoStateName, "To do", "Todo"], + ["unstarted", "backlog"], + ); + if (!state) { + throw new Error(`Could not resolve QA "Todo" state (team ${cfg.qaTeamKey}).`); + } + return { qaTeam, state }; +} + +const _projects = new Map(); +async function getOrCreateProject(name, teamId) { + if (_projects.has(name)) return _projects.get(name); + const res = await client.projects({ filter: { name: { eq: name } } }); + let project = res.nodes[0]; + if (!project) { + const created = await client.createProject({ name, teamIds: [teamId] }); + project = await created.project; + console.log(`Created Linear project "${name}".`); + } + _projects.set(name, project); + return project; +} + +// Optional project for un-milestoned PRs. Returns null when unconfigured or missing, +// in which case the parent simply lives in the team's Triage with no project. +async function findCoreProject(teamId) { + if (!cfg.coreProjectName) return null; + return await getOrCreateProject(cfg.coreProjectName, teamId); +} + +const _users = new Map(); +async function getUserByEmail(email) { + if (!email) return null; + if (_users.has(email)) return _users.get(email); + const res = await client.users({ filter: { email: { eq: email } } }); + const user = res.nodes[0] || null; + _users.set(email, user); + return user; +} + +async function getOrCreateLabel(name, teamId) { + const res = await client.issueLabels({ filter: { name: { eq: name } } }); + const existing = res.nodes.find((l) => l.name.toLowerCase() === name.toLowerCase()); + if (existing) return existing; + const created = await client.createIssueLabel({ name, teamId }); + console.log(`Created Linear label "${name}".`); + return await created.issueLabel; +} + +// Find the parent issue previously created for this PR, via its attachment URL. +async function findParentIssue(pr) { + let attachments; + try { + attachments = await client.attachmentsForURL(pr.url); + } catch (e) { + console.log(`attachmentsForURL failed: ${e.message}`); + return null; + } + for (const att of attachments.nodes) { + const issue = await att.issue; + if (!issue) continue; + const parentRef = await issue.parent; + if (!parentRef) return issue; // parent issue has no parent of its own + } + return null; +} + +async function getChildren(issue) { + const res = await issue.children(); + return res.nodes; +} + +async function childAssigneeId(child) { + const assignee = await child.assignee; + return assignee ? assignee.id : null; +} + +async function childTeamId(child) { + const team = await child.team; + return team ? team.id : null; +} + +// Returns the parent's project id, moving it (and children) into the "unsorted" +// bucket first if it has none (e.g. merged without a milestone). +async function ensureParentProject(pr, parent, children, team, { logLabel }) { + const current = await parent.project; + if (current) return current.id; + const project = await getOrCreateProject(cfg.unsortedProjectName, team.id); + await client.updateIssue(parent.id, { projectId: project.id }); + for (const child of children) { + await client.updateIssue(child.id, { projectId: project.id }); + } + console.log( + `No milestone on ${logLabel} -> moved ${parent.identifier} to "${cfg.unsortedProjectName}".`, + ); + return project.id; +} + +// Map Linear user id -> reviewer config, so we can tell which review sub-issue +// belongs to which reviewer by its assignee. +async function reviewerByUserId() { + const map = new Map(); + for (const r of cfg.reviewers) { + const user = await getUserByEmail(r.email); + if (user) map.set(user.id, r); + } + return map; +} + +// Set of lowercased GitHub logins whose *latest* review on the PR is an approval. +async function fetchApprovedReviewerLogins(pr) { + const approved = new Set(); + const token = process.env.GH_TOKEN; + if (!token) { + console.log("No GH_TOKEN; cannot read PR reviews (treating as no approvals)."); + return approved; + } + const res = await fetch( + `https://api.github.com/repos/${pr.repo}/pulls/${pr.number}/reviews?per_page=100`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + }, + ); + if (!res.ok) { + console.log(`Could not fetch PR reviews (${res.status}).`); + return approved; + } + const reviews = await res.json(); + const latest = new Map(); + for (const rv of reviews) { + const login = rv.user?.login?.toLowerCase(); + if (!login) continue; + const state = (rv.state || "").toUpperCase(); + if (state === "COMMENTED") continue; // comment-only reviews don't change approval + latest.set(login, state); // reviews are chronological; keep the last + } + for (const [login, state] of latest) { + if (state === "APPROVED") approved.add(login); + } + return approved; +} + +// When a milestoned PR is merged, hand it off to Q&A: create a "<title> — Testing" +// sub-issue in the QA team (state Todo), assigned to the QA owner, with the reviewers +// subscribed so they get notified. +async function maybeCreateQaTestingIssue(pr, parent, children, qaTeam, projectId) { + if (!projectId) { + console.log("Parent has no project; skipping Q&A testing sub-issue."); + return; + } + + // Idempotency: don't create a second QA sub-issue on a duplicate merge event. + for (const child of children) { + if ((await childTeamId(child)) === qaTeam.id) { + console.log("Q&A testing sub-issue already exists; skipping."); + return; + } + } + + const { state: todo } = await getQaTodoState(); + const assignee = await getUserByEmail(cfg.qaAssigneeEmail); + + const subscriberIds = []; + for (const email of cfg.qaSubscriberEmails) { + const user = await getUserByEmail(email); + if (user) subscriberIds.push(user.id); + } + + await client.createIssue({ + teamId: qaTeam.id, + parentId: parent.id, + projectId, + stateId: todo.id, + title: `${pr.title} — ${cfg.testingSuffix}`, + assigneeId: assignee ? assignee.id : undefined, + subscriberIds: subscriberIds.length ? subscriberIds : undefined, + description: qaTestingDescription(pr), + }); + console.log( + `Created Q&A testing sub-issue (assignee ${cfg.qaAssigneeEmail}${assignee ? "" : " — not found, unassigned"}).`, + ); +} + +function qaTestingDescription(pr) { + const cc = cfg.qaSubscriberEmails.map((e) => `@${e}`).join(" "); + return [ + `The pull request linked to the parent issue (${pr.url}) has been merged and is ready for testing.`, + "", + `cc: ${cc}`, + ].join("\n"); +} + +// Request the configured reviewers on the GitHub PR itself. Uses the Actions token, +// which has write access to the base repo even for fork PRs (pull_request_target). +async function requestGithubReviewers(pr) { + const logins = reviewersFor(pr.authorLogin) + .map((r) => r.github) + .filter(Boolean); + if (!logins.length) return; + + const token = process.env.GH_TOKEN; + if (!token) { + console.log("No GH_TOKEN available; skipping GitHub reviewer request."); + return; + } + + const res = await fetch( + `https://api.github.com/repos/${pr.repo}/pulls/${pr.number}/requested_reviewers`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ reviewers: logins }), + }, + ); + if (!res.ok) { + // 422 typically means a login isn't a collaborator or is the PR author. + console.log( + `GitHub reviewer request returned ${res.status}: ${await res.text()}`, + ); + } else { + console.log(`Requested GitHub reviewers: ${logins.join(", ")}`); + } +} + +// --------------------------------------------------------------------------- +// GitHub event parsing +// --------------------------------------------------------------------------- + +async function loadPullRequest() { + // Manual re-run path: fetch PR details via the GitHub API. + if (process.env.MANUAL_PR_NUMBER && !process.env.GITHUB_EVENT_ACTION) { + return await loadPullRequestFromApi( + process.env.MANUAL_REPO, + process.env.MANUAL_PR_NUMBER, + ); + } + + const eventPath = process.env.GITHUB_EVENT_PATH; + if (!eventPath || !fs.existsSync(eventPath)) { + throw new Error("GITHUB_EVENT_PATH not available."); + } + const event = JSON.parse(fs.readFileSync(eventPath, "utf8")); + const pull = event.pull_request; + if (!pull) { + console.log("Event has no pull_request payload, skipping."); + return null; + } + + const merged = Boolean(pull.merged); + const closerLogin = merged + ? pull.merged_by?.login + : event.sender?.login; + + return { + repo: event.repository?.full_name, + number: pull.number, + title: pull.title, + url: pull.html_url, + authorLogin: pull.user?.login, + milestone: pull.milestone?.title || null, + merged, + closerLogin, + firstTimeContributor: + pull.author_association === "FIRST_TIME_CONTRIBUTOR" || + pull.author_association === "FIRST_TIMER", + review: event.review + ? { state: event.review.state, login: event.review.user?.login } + : null, + }; +} + +async function loadPullRequestFromApi(repo, number) { + const token = process.env.GH_TOKEN; + const res = await fetch( + `https://api.github.com/repos/${repo}/pulls/${number}`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + }, + ); + if (!res.ok) throw new Error(`GitHub API ${res.status}: ${await res.text()}`); + const pull = await res.json(); + return { + repo, + number: pull.number, + title: pull.title, + url: pull.html_url, + authorLogin: pull.user?.login, + milestone: pull.milestone?.title || null, + merged: Boolean(pull.merged), + closerLogin: pull.merged_by?.login, + firstTimeContributor: + pull.author_association === "FIRST_TIME_CONTRIBUTOR" || + pull.author_association === "FIRST_TIMER", + }; +} + +function parentDescription(pr) { + return [ + `GitHub pull request: ${pr.url}`, + `Author: @${pr.authorLogin}`, + pr.firstTimeContributor ? "First-time contributor 🎉" : "", + "", + prMarker(pr.repo, pr.number), + ] + .filter(Boolean) + .join("\n"); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function requireEnv(name) { + const v = process.env[name]; + if (!v) throw new Error(`Missing required environment variable: ${name}`); + return v; +} + +function warnNoParent(pr) { + console.log( + `No Linear parent issue found for ${pr.repo}#${pr.number}; nothing to update.`, + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/.github/workflows/linear-pr-sync.yml b/.github/workflows/linear-pr-sync.yml new file mode 100644 index 0000000000..955b024cb5 --- /dev/null +++ b/.github/workflows/linear-pr-sync.yml @@ -0,0 +1,109 @@ +name: Linear PR Sync + +# Mirrors every incoming pull request (including from external forks) into Linear. +# +# Why pull_request_target: PRs from forks do NOT get access to repository secrets +# when using the plain `pull_request` trigger, so LINEAR_API_KEY would be empty and +# the sync would silently fail for external contributors (the main use case here). +# `pull_request_target` runs in the context of the BASE repo and has access to secrets. +# +# Security note: we intentionally do NOT check out or execute any code from the PR head. +# The default checkout ref for pull_request_target is the base branch, and we only read +# PR metadata (from the event payload) and call the Linear API. Do not add a step that +# checks out `github.event.pull_request.head.ref` here. + +on: + pull_request_target: + types: + - opened + - reopened + - closed + - milestoned + - demilestoned + + # Reviews (approvals). This event runs in the base-repo context and has secrets, + # even for fork PRs, so no *_target variant is needed. + pull_request_review: + types: + - submitted + + # Manual re-run helper: pass a PR number to re-sync a single PR. + workflow_dispatch: + inputs: + pr_number: + description: "PR number to re-sync (uses current 'opened' logic)" + required: true + type: string + +concurrency: + # Serialize events per PR so parallel webhooks don't race on the same Linear issue. + group: linear-pr-sync-${{ github.event.pull_request.number || github.event.inputs.pr_number }} + cancel-in-progress: false + +permissions: + contents: read + # Needed to auto-request reviewers on the PR. Works for fork PRs because + # pull_request_target runs with the base repo's token. + pull-requests: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Checkout base repo (scripts only) + uses: actions/checkout@v4 + # Default ref = base branch. Never checkout PR head code with pull_request_target. + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install dependencies + working-directory: .github/scripts/linear-pr-sync + run: npm install --no-audit --no-fund + + - name: Sync PR to Linear + working-directory: .github/scripts/linear-pr-sync + env: + # --- Required secret --- + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + + # --- Required config (set as repository Variables) --- + LINEAR_TEAM_KEY: ${{ vars.LINEAR_TEAM_KEY }} + # JSON array: [{"github":"login","email":"user@linear","name":"Full Name"}, ...] + REVIEWERS: ${{ vars.LINEAR_REVIEWERS }} + + # --- Optional config (sensible defaults in the script) --- + # Leave CORE_PROJECT_NAME empty: the "Gonka core" folder is the GON team itself, + # so un-milestoned PRs stay in the team's Triage with no project. + CORE_PROJECT_NAME: ${{ vars.LINEAR_CORE_PROJECT_NAME }} + # GitHub milestone "v0.2.15" -> Linear project "Upgrade v0.2.15". + MILESTONE_PROJECT_PREFIX: ${{ vars.LINEAR_MILESTONE_PROJECT_PREFIX }} + # Bucket for PRs merged/approved without a milestone. + UNSORTED_PROJECT_NAME: ${{ vars.LINEAR_UNSORTED_PROJECT_NAME }} + FIRST_CONTRIBUTOR_LABEL: ${{ vars.LINEAR_FIRST_CONTRIBUTOR_LABEL }} + BACKLOG_STATE_NAME: ${{ vars.LINEAR_BACKLOG_STATE_NAME }} + DONE_STATE_NAME: ${{ vars.LINEAR_DONE_STATE_NAME }} + CANCELLED_STATE_NAME: ${{ vars.LINEAR_CANCELLED_STATE_NAME }} + NOT_DONE_STATE_NAME: ${{ vars.LINEAR_NOT_DONE_STATE_NAME }} + RELEASE_START_STATE_NAME: ${{ vars.LINEAR_RELEASE_START_STATE_NAME }} + MERGED_STATE_NAME: ${{ vars.LINEAR_MERGED_STATE_NAME }} + + # --- Q&A testing hand-off (on merge of a milestoned PR) --- + QA_TEAM_KEY: ${{ vars.LINEAR_QA_TEAM_KEY }} + QA_TODO_STATE_NAME: ${{ vars.LINEAR_QA_TODO_STATE_NAME }} + TESTING_SUFFIX: ${{ vars.LINEAR_TESTING_SUFFIX }} + QA_ASSIGNEE_EMAIL: ${{ vars.LINEAR_QA_ASSIGNEE_EMAIL }} + QA_SUBSCRIBER_EMAILS: ${{ vars.LINEAR_QA_SUBSCRIBER_EMAILS }} + + # --- GitHub reviewer auto-request --- + REQUEST_GITHUB_REVIEWERS: ${{ vars.LINEAR_REQUEST_GITHUB_REVIEWERS }} + + # --- GitHub context --- + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_EVENT_ACTION: ${{ github.event.action }} + MANUAL_PR_NUMBER: ${{ github.event.inputs.pr_number }} + MANUAL_REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + run: node sync.mjs diff --git a/docs/linear-github-sync.md b/docs/linear-github-sync.md new file mode 100644 index 0000000000..57eda4210e --- /dev/null +++ b/docs/linear-github-sync.md @@ -0,0 +1,58 @@ +# Синхронизация GitHub PR → Linear + +Автоматизация: каждый пул-реквест в репозитории `gonka-ai/gonka` (включая PR от внешних +контрибьюторов из форков) автоматически заводится и ведётся тикетом в Linear в команде +**GON / Gonka-core**. Внутренние комментарии в Linear наружу в GitHub **не попадают**. + +Реализовано кастомным GitHub Action, потому что штатная интеграция Linear такого не умеет: +- workflow: `.github/workflows/linear-pr-sync.yml` +- логика: `.github/scripts/linear-pr-sync/` (см. README там же) + +## Что происходит автоматически + +| Событие в GitHub | Что делается в Linear | +|---|---| +| PR **открыт** (без milestone) | Parent-тикет в команде GON, статус **Triage**. First-time contributor → лейбл. Review-сабтикеты «`<название> — review needed`» (Backlog) на Диму и Гаврю. Ревьюеры запрашиваются и на самом PR. | +| PR открыт автором-ревьюером | Если PR открыл Дима — ревью только на Гаврю, и наоборот. | +| **Навесили milestone** (релиз) | Parent + сабтикеты переезжают из Triage в проект релиза `Upgrade <milestone>` (создаётся, если его нет). | +| Ревьюер **аппрувнул** PR | Его review-сабтикет → **Done**. | +| PR **смёржен** | Parent → **Merged. Ready for testing**. Review-сабтикет → **Done** только если ревьюер реально аппрувил, иначе → **Not done**. Создаётся QA-сабтикет «`<название> — Testing`» (Todo) на **Марию Митину**, в описании отмечены Мария и dbogdan. | +| PR **закрыт** Димой/Гаврей (без merge) | Parent → **Done**, их сабтикет → **Done**, второй → **Cancelled**. | +| PR **закрыт** кем-то посторонним (без merge) | Parent → **Cancelled**, review-сабтикеты → **Not done**, QA-тикет не создаётся. | +| **merged/approved без milestone** | Parent уезжает в разборочный проект **«Merged — no milestone (to sort)»** — видно, что влилось/одобрено, но не распределено по релизам. Остальной пайплайн отрабатывает. | + +## Настройка (что нужно один раз сделать) + +### 1. Linear API-ключ → GitHub Secret +1. Linear → Settings → API → создать Personal API key (лучше от служебного аккаунта). +2. GitHub → репозиторий → Settings → Secrets and variables → Actions → **Secrets** → + New repository secret: имя `LINEAR_API_KEY`, значение — ключ. + +> Ключ хранится **только** в GitHub Secrets, в коде/репозитории его нет. + +### 2. Repository Variables +GitHub → Settings → Secrets and variables → Actions → **Variables**: + +| Variable | Обязательна | Значение | +|---|---|---| +| `LINEAR_TEAM_KEY` | да | `GON` | +| `LINEAR_REVIEWERS` | да | JSON (см. ниже) | + +`LINEAR_REVIEWERS` (одной строкой): + +```json +[{"github":"DimaOrekhovPS","email":"dima.orekhov@productscience.ai","name":"Dima Orekhov"},{"github":"GLiberman","email":"gabriel@productscience.ai","name":"Gabriel Liberman"}] +``` + +Остальные переменные опциональны — дефолты уже совпадают с нашим воркспейсом +(статусы `Backlog`/`Done`/`Canceled`/`Not done`/`Merged. Ready for testing`, команда `QA`, +ассайн QA `maria.mitina@productscience.ai`, префикс проектов `Upgrade ` и т.д.). +Полный список — в `.github/scripts/linear-pr-sync/README.md`. + +### 3. Доступы +- Дима и Габриель должны быть **коллабораторами репозитория** — иначе GitHub не даст + запросить у них ревью (тикеты в Linear всё равно создадутся). + +## Как поменять поведение +Почти всё настраивается через Variables без правки кода: ревьюеры, названия статусов/проектов, +ассайн QA, суффиксы тикетов, вкл/выкл авто-запрос ревьюеров (`LINEAR_REQUEST_GITHUB_REVIEWERS=false`). From caf53aa3256a8ccf683a330859a1d46385a8b926 Mon Sep 17 00:00:00 2001 From: Tania Charchian <tatiana.charchian@productscience.ai> Date: Fri, 10 Jul 2026 12:21:13 -0700 Subject: [PATCH 2/4] ci: cancel review sub-issues when PR closed by a non-reviewer Previously review sub-issues were set to "Not done" when a PR was closed unmerged by someone other than a reviewer; make them "Cancelled" instead (the parent is already Cancelled). "Not done" remains for the merged-but- not-approved case. Co-authored-by: Cursor <cursoragent@cursor.com> --- .github/scripts/linear-pr-sync/README.md | 2 +- .github/scripts/linear-pr-sync/sync.mjs | 11 +++++------ docs/linear-github-sync.md | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/scripts/linear-pr-sync/README.md b/.github/scripts/linear-pr-sync/README.md index ffa1dae096..a1c23292a0 100644 --- a/.github/scripts/linear-pr-sync/README.md +++ b/.github/scripts/linear-pr-sync/README.md @@ -19,7 +19,7 @@ logic below — hence this Action. | PR **review submitted = approved** by a reviewer | That reviewer's review sub-issue → **Done**. If the PR has no milestone, the parent is parked in the **unsorted** project so approved-but-unassigned work is visible. | | PR **merged** | Parent → **Merged. Ready for testing**. Each review sub-issue → **Done** *only if that reviewer actually approved on GitHub*, otherwise → **Not done**. A **"`<title>` — Testing"** sub-issue is created in the **Q&A** team (state **Todo**), assigned to the QA owner, reviewers subscribed. If there was no milestone, the parent is first moved into the **unsorted** project (full pipeline still runs). | | PR **closed** by a reviewer (Dima / Gabriel) without merge | Parent → **Done**, that reviewer's sub-issue → **Done**, the other sub-issue → **Cancelled**. | -| PR **closed** by anyone else without merge | Parent → **Cancelled**, review sub-issues → **Not done**. No Q&A testing sub-issue is created. | +| PR **closed** by anyone else without merge | Parent + review sub-issues → **Cancelled**. No Q&A testing sub-issue is created. | **Reviewer assignment rule:** normally a sub-issue is created for (and a GitHub review is requested from) each reviewer. If the PR author *is* one of the reviewers, only the *other* diff --git a/.github/scripts/linear-pr-sync/sync.mjs b/.github/scripts/linear-pr-sync/sync.mjs index 6df8e1e8c5..599ed4fac5 100644 --- a/.github/scripts/linear-pr-sync/sync.mjs +++ b/.github/scripts/linear-pr-sync/sync.mjs @@ -10,7 +10,7 @@ // is created (state "Todo", assigned to the QA owner) // closed (unmerged) -> if closed by a reviewer: their sub-issue -> Done, parent -> Done, // other sub-issue -> Cancelled -// otherwise: parent -> Cancelled, review sub-issues -> Not done +// otherwise: parent + review sub-issues -> Cancelled // (no Q&A testing sub-issue is created) // // All identity/state resolution is done by NAME at runtime, so the only hard config @@ -300,15 +300,14 @@ async function onClosed(pr) { return; } - // Closed by someone who is not a reviewer (no merge): cancel the parent, and mark - // the review sub-issues as "Not done". No Q&A testing sub-issue is created (that only - // happens on merge). + // Closed by someone who is not a reviewer (no merge): cancel the parent and the + // review sub-issues. No Q&A testing sub-issue is created (that only happens on merge). await client.updateIssue(parent.id, { stateId: states.cancelled.id }); for (const child of children) { - await client.updateIssue(child.id, { stateId: states.notDone.id }); + await client.updateIssue(child.id, { stateId: states.cancelled.id }); } console.log( - `PR closed by ${pr.closerLogin || "unknown"} -> ${parent.identifier} Cancelled, review sub-issues Not done.`, + `PR closed by ${pr.closerLogin || "unknown"} -> ${parent.identifier} and review sub-issues Cancelled.`, ); } diff --git a/docs/linear-github-sync.md b/docs/linear-github-sync.md index 57eda4210e..32e08b3b87 100644 --- a/docs/linear-github-sync.md +++ b/docs/linear-github-sync.md @@ -18,7 +18,7 @@ | Ревьюер **аппрувнул** PR | Его review-сабтикет → **Done**. | | PR **смёржен** | Parent → **Merged. Ready for testing**. Review-сабтикет → **Done** только если ревьюер реально аппрувил, иначе → **Not done**. Создаётся QA-сабтикет «`<название> — Testing`» (Todo) на **Марию Митину**, в описании отмечены Мария и dbogdan. | | PR **закрыт** Димой/Гаврей (без merge) | Parent → **Done**, их сабтикет → **Done**, второй → **Cancelled**. | -| PR **закрыт** кем-то посторонним (без merge) | Parent → **Cancelled**, review-сабтикеты → **Not done**, QA-тикет не создаётся. | +| PR **закрыт** кем-то посторонним (без merge) | Parent + review-сабтикеты → **Cancelled**, QA-тикет не создаётся. | | **merged/approved без milestone** | Parent уезжает в разборочный проект **«Merged — no milestone (to sort)»** — видно, что влилось/одобрено, но не распределено по релизам. Остальной пайплайн отрабатывает. | ## Настройка (что нужно один раз сделать) From 8617932fbd06aaa137b3417e027d25a63e57da66 Mon Sep 17 00:00:00 2001 From: Tania Charchian <tatiana.charchian@productscience.ai> Date: Fri, 10 Jul 2026 15:26:56 -0700 Subject: [PATCH 3/4] Delete docs/linear-github-sync.md Signed-off-by: Tania Charchian <tatiana.charchian@productscience.ai> --- docs/linear-github-sync.md | 58 -------------------------------------- 1 file changed, 58 deletions(-) delete mode 100644 docs/linear-github-sync.md diff --git a/docs/linear-github-sync.md b/docs/linear-github-sync.md deleted file mode 100644 index 32e08b3b87..0000000000 --- a/docs/linear-github-sync.md +++ /dev/null @@ -1,58 +0,0 @@ -# Синхронизация GitHub PR → Linear - -Автоматизация: каждый пул-реквест в репозитории `gonka-ai/gonka` (включая PR от внешних -контрибьюторов из форков) автоматически заводится и ведётся тикетом в Linear в команде -**GON / Gonka-core**. Внутренние комментарии в Linear наружу в GitHub **не попадают**. - -Реализовано кастомным GitHub Action, потому что штатная интеграция Linear такого не умеет: -- workflow: `.github/workflows/linear-pr-sync.yml` -- логика: `.github/scripts/linear-pr-sync/` (см. README там же) - -## Что происходит автоматически - -| Событие в GitHub | Что делается в Linear | -|---|---| -| PR **открыт** (без milestone) | Parent-тикет в команде GON, статус **Triage**. First-time contributor → лейбл. Review-сабтикеты «`<название> — review needed`» (Backlog) на Диму и Гаврю. Ревьюеры запрашиваются и на самом PR. | -| PR открыт автором-ревьюером | Если PR открыл Дима — ревью только на Гаврю, и наоборот. | -| **Навесили milestone** (релиз) | Parent + сабтикеты переезжают из Triage в проект релиза `Upgrade <milestone>` (создаётся, если его нет). | -| Ревьюер **аппрувнул** PR | Его review-сабтикет → **Done**. | -| PR **смёржен** | Parent → **Merged. Ready for testing**. Review-сабтикет → **Done** только если ревьюер реально аппрувил, иначе → **Not done**. Создаётся QA-сабтикет «`<название> — Testing`» (Todo) на **Марию Митину**, в описании отмечены Мария и dbogdan. | -| PR **закрыт** Димой/Гаврей (без merge) | Parent → **Done**, их сабтикет → **Done**, второй → **Cancelled**. | -| PR **закрыт** кем-то посторонним (без merge) | Parent + review-сабтикеты → **Cancelled**, QA-тикет не создаётся. | -| **merged/approved без milestone** | Parent уезжает в разборочный проект **«Merged — no milestone (to sort)»** — видно, что влилось/одобрено, но не распределено по релизам. Остальной пайплайн отрабатывает. | - -## Настройка (что нужно один раз сделать) - -### 1. Linear API-ключ → GitHub Secret -1. Linear → Settings → API → создать Personal API key (лучше от служебного аккаунта). -2. GitHub → репозиторий → Settings → Secrets and variables → Actions → **Secrets** → - New repository secret: имя `LINEAR_API_KEY`, значение — ключ. - -> Ключ хранится **только** в GitHub Secrets, в коде/репозитории его нет. - -### 2. Repository Variables -GitHub → Settings → Secrets and variables → Actions → **Variables**: - -| Variable | Обязательна | Значение | -|---|---|---| -| `LINEAR_TEAM_KEY` | да | `GON` | -| `LINEAR_REVIEWERS` | да | JSON (см. ниже) | - -`LINEAR_REVIEWERS` (одной строкой): - -```json -[{"github":"DimaOrekhovPS","email":"dima.orekhov@productscience.ai","name":"Dima Orekhov"},{"github":"GLiberman","email":"gabriel@productscience.ai","name":"Gabriel Liberman"}] -``` - -Остальные переменные опциональны — дефолты уже совпадают с нашим воркспейсом -(статусы `Backlog`/`Done`/`Canceled`/`Not done`/`Merged. Ready for testing`, команда `QA`, -ассайн QA `maria.mitina@productscience.ai`, префикс проектов `Upgrade ` и т.д.). -Полный список — в `.github/scripts/linear-pr-sync/README.md`. - -### 3. Доступы -- Дима и Габриель должны быть **коллабораторами репозитория** — иначе GitHub не даст - запросить у них ревью (тикеты в Linear всё равно создадутся). - -## Как поменять поведение -Почти всё настраивается через Variables без правки кода: ревьюеры, названия статусов/проектов, -ассайн QA, суффиксы тикетов, вкл/выкл авто-запрос ревьюеров (`LINEAR_REQUEST_GITHUB_REVIEWERS=false`). From 1f6f5c095a0ff4f4c35f5d5af9f11ae4a2cd2f42 Mon Sep 17 00:00:00 2001 From: Tania Charchian <tatiana.charchian@productscience.ai> Date: Fri, 10 Jul 2026 15:27:25 -0700 Subject: [PATCH 4/4] Delete .github/scripts/linear-pr-sync/README.md Signed-off-by: Tania Charchian <tatiana.charchian@productscience.ai> --- .github/scripts/linear-pr-sync/README.md | 93 ------------------------ 1 file changed, 93 deletions(-) delete mode 100644 .github/scripts/linear-pr-sync/README.md diff --git a/.github/scripts/linear-pr-sync/README.md b/.github/scripts/linear-pr-sync/README.md deleted file mode 100644 index a1c23292a0..0000000000 --- a/.github/scripts/linear-pr-sync/README.md +++ /dev/null @@ -1,93 +0,0 @@ -# Linear ↔ GitHub PR sync - -Mirrors every incoming pull request (including PRs from external forks) into Linear -and keeps the Linear issues in sync with the PR lifecycle. - -This is a **custom** integration on top of the Linear API, not the native Linear GitHub -integration. The native integration cannot auto-create an issue per external PR, detect -first-time contributors, build the parent/sub-issue structure, or apply the reviewer/close -logic below — hence this Action. - -## What it does - -| GitHub event | Result in Linear | -|---|---| -| PR **opened** / **reopened** (no milestone) | Parent issue created in the **GON** team **Triage** (no project). First-time contributor → label added. One **"`<title>` — review needed"** sub-issue (state **Backlog**) per reviewer. The reviewers are also **requested on the GitHub PR** itself. | -| PR **opened** with a milestone already set | Same, but parent + sub-issues go straight into the release project (named after the milestone), not Triage. | -| PR **milestoned** (release milestone added) | Parent (and its sub-issues) move out of Triage into the project named after the milestone (created if missing). | -| PR **demilestoned** | Parent moves back to the GON team / Triage. | -| PR **review submitted = approved** by a reviewer | That reviewer's review sub-issue → **Done**. If the PR has no milestone, the parent is parked in the **unsorted** project so approved-but-unassigned work is visible. | -| PR **merged** | Parent → **Merged. Ready for testing**. Each review sub-issue → **Done** *only if that reviewer actually approved on GitHub*, otherwise → **Not done**. A **"`<title>` — Testing"** sub-issue is created in the **Q&A** team (state **Todo**), assigned to the QA owner, reviewers subscribed. If there was no milestone, the parent is first moved into the **unsorted** project (full pipeline still runs). | -| PR **closed** by a reviewer (Dima / Gabriel) without merge | Parent → **Done**, that reviewer's sub-issue → **Done**, the other sub-issue → **Cancelled**. | -| PR **closed** by anyone else without merge | Parent + review sub-issues → **Cancelled**. No Q&A testing sub-issue is created. | - -**Reviewer assignment rule:** normally a sub-issue is created for (and a GitHub review is -requested from) each reviewer. If the PR author *is* one of the reviewers, only the *other* -reviewer(s) apply (e.g. a PR by Dima is reviewed only by Gabriel, and vice versa). - -**Private comments:** internal comments you write on the Linear issue stay in Linear. Nothing -is pushed to GitHub unless explicitly added to this script, so your triage discussion is not -visible to external contributors. - -## Setup - -### 1. Create a Linear API key -Linear → Settings → API → Personal API keys (or an OAuth app token for a service account). -Add it as a **repository secret** named `LINEAR_API_KEY`. - -> Prefer a dedicated service-account user in Linear so issues/comments aren't attributed to a person. - -### 2. Add repository Variables -Settings → Secrets and variables → Actions → **Variables**: - -| Variable | Required | Value for this workspace | Notes | -|---|---|---|---| -| `LINEAR_TEAM_KEY` | ✅ | `GON` | Team "Gonka-core"; the key on issue ids like `GON-123`. | -| `LINEAR_REVIEWERS` | ✅ | see below | Maps GitHub logins to Linear users. | -| `LINEAR_MILESTONE_PROJECT_PREFIX` | – | `Upgrade ` | GitHub milestone `v0.2.15` → project `Upgrade v0.2.15`. Default already `Upgrade `. | -| `LINEAR_UNSORTED_PROJECT_NAME` | – | `Merged — no milestone (to sort)` | Bucket for PRs merged/approved without a milestone. Created if missing. | -| `LINEAR_CORE_PROJECT_NAME` | – | *(leave empty)* | The "Gonka core" folder is the GON team; un-milestoned PRs stay in Triage with no project. | -| `LINEAR_FIRST_CONTRIBUTOR_LABEL` | – | `first-time contributor` | Created if missing. (Existing alternatives: `contributor`, `Community-driven`.) | -| `LINEAR_BACKLOG_STATE_NAME` | – | `Backlog` | Default. | -| `LINEAR_DONE_STATE_NAME` | – | `Done` | Default. | -| `LINEAR_CANCELLED_STATE_NAME` | – | `Canceled` | Note the single-l spelling in this workspace. | -| `LINEAR_NOT_DONE_STATE_NAME` | – | `Not done` | Review sub-issue state when the reviewer never approved. | -| `LINEAR_RELEASE_START_STATE_NAME` | – | `Backlog` | State the parent gets when a milestone moves it into a release project. | -| `LINEAR_MERGED_STATE_NAME` | – | `Merged. Ready for testing` | State the parent gets on merge. | -| `LINEAR_QA_TEAM_KEY` | – | `QA` | Team that owns testing sub-issues. | -| `LINEAR_QA_TODO_STATE_NAME` | – | `Todo` | State for the QA testing sub-issue. | -| `LINEAR_TESTING_SUFFIX` | – | `Testing` | Title suffix: `<title> — Testing`. | -| `LINEAR_QA_ASSIGNEE_EMAIL` | – | `maria.mitina@productscience.ai` | Assignee of the QA testing sub-issue. | -| `LINEAR_QA_SUBSCRIBER_EMAILS` | – | `dbogdan@engenious.io,maria.mitina@productscience.ai` | Comma-separated; subscribed/mentioned on the QA sub-issue. | -| `LINEAR_REQUEST_GITHUB_REVIEWERS` | – | `true` | Set `false` to stop requesting reviewers on the PR. | - -`LINEAR_REVIEWERS` is a JSON array (single line). `github` = GitHub login, `email` = the -person's Linear account email, `name` = display name for logs: - -```json -[{"github":"DimaOrekhovPS","email":"dima.orekhov@productscience.ai","name":"Dima Orekhov"},{"github":"GLiberman","email":"gabriel@productscience.ai","name":"Gabriel Liberman"}] -``` - -### 3. Done -The workflow at `.github/workflows/linear-pr-sync.yml` runs automatically. To re-sync a single -PR manually, use the workflow's **Run workflow** button and pass the PR number. - -## Notes & assumptions - -- Uses `pull_request_target` so secrets are available for fork PRs. It never checks out or runs - PR code — only reads metadata and calls Linear. Do not add a checkout of the PR head. -- Workflow states, projects, users and labels are resolved **by name** at runtime, so no Linear - IDs are hard-coded. Missing projects and the label are created automatically. -- The PR ↔ issue link is stored as a Linear **attachment** on the parent issue (the PR URL), - which is how later events (milestone/close) find the right issue. -- On merge the parent moves to **Merged. Ready for testing**. Each review sub-issue is set to - **Done** only when that reviewer's latest GitHub review is an *approval*; otherwise it becomes - **Not done**. Approvals are read from the GitHub reviews API (comment-only reviews are ignored). -- If a PR is merged or approved **without a milestone**, it's assumed the milestone was simply - forgotten: the parent is moved into the **unsorted** project (`LINEAR_UNSORTED_PROJECT_NAME`) - so you can see everything that landed as merged/approved but isn't assigned to a release, and - sort it manually. The rest of the pipeline (merged state, Q&A hand-off) still runs. -- Requesting a GitHub reviewer requires the person to be a repo collaborator; if not, GitHub - returns 422 and the script logs it and continues (the Linear sub-issue is still created). -- The QA sub-issue subscribes `LINEAR_QA_SUBSCRIBER_EMAILS` (so they are notified) and lists - them in the description.