From 66ddefa49ca4fd99ad36ee8384af65c05bb94fa8 Mon Sep 17 00:00:00 2001 From: ulricden Date: Fri, 17 Jul 2026 15:09:12 +0200 Subject: [PATCH 1/6] feat: add reusable release-notes action Generalizes smartapp's create_release_notes.py into a composite action usable by any repo: Linear issue detection is no longer limited to SORD tickets (configurable regex, defaults to any team prefix), and repo/Notion DB/refs are parameterized instead of hardcoded. Co-Authored-By: Claude Sonnet 5 --- .github/actions/release-notes/action.yml | 65 +++ .../release-notes/create_release_notes.py | 417 ++++++++++++++++++ 2 files changed, 482 insertions(+) create mode 100644 .github/actions/release-notes/action.yml create mode 100644 .github/actions/release-notes/create_release_notes.py diff --git a/.github/actions/release-notes/action.yml b/.github/actions/release-notes/action.yml new file mode 100644 index 00000000..57fbfc45 --- /dev/null +++ b/.github/actions/release-notes/action.yml @@ -0,0 +1,65 @@ +name: Create release notes on Notion + +description: | + Create a Notion release notes page from merge commits between two git + refs, resolving any Linear issue keys (SORD-123, FOS-42, ...) referenced + in the commit messages. + + Requires the repository to be checked out (actions/checkout with + fetch-depth: 0) before this action runs. + +inputs: + version: + description: "Full version string (e.g. 1.4.4.23588748828)" + required: true + release_type: + description: "Release type (value stored in the 'Mobile app release type' Notion property)" + required: true + pr_source: + description: "Go-to-prod PR number, used to build the 'Go to prod' link in the release notes" + required: true + notion_database_id: + description: "Notion database ID to create the release notes page into" + required: true + linear_api_key: + description: "Linear API token" + required: true + notion_api_key: + description: "Notion integration token" + required: true + linear_workspace: + description: "Linear workspace slug, used to build issue URLs when Linear does not return one" + required: false + default: "smartway" + linear_issue_pattern: + description: "Regex used to detect Linear issue keys in commit messages. Defaults to matching any team prefix (e.g. SORD-123, FOS-42); restrict it (e.g. 'SORD-\\d+') to only match specific teams" + required: false + default: '[A-Z]{2,10}-\d+' + +outputs: + page_url: + description: "URL of the created Notion page" + value: ${{ steps.create_release_notes.outputs.page_url }} + page_id: + description: "ID of the created Notion page" + value: ${{ steps.create_release_notes.outputs.page_id }} + +runs: + using: "composite" + steps: + - name: Create Notion release notes + id: create_release_notes + shell: bash + env: + LINEAR_API_KEY: ${{ inputs.linear_api_key }} + NOTION_API_KEY: ${{ inputs.notion_api_key }} + VERSION: ${{ inputs.version }} + RELEASE_TYPE: ${{ inputs.release_type }} + PR_SOURCE: ${{ inputs.pr_source }} + NOTION_DATABASE_ID: ${{ inputs.notion_database_id }} + LINEAR_WORKSPACE: ${{ inputs.linear_workspace }} + LINEAR_ISSUE_PATTERN: ${{ inputs.linear_issue_pattern }} + REPO: ${{ github.repository }} + FROM_REF: ${{ github.event.pull_request.base.ref || 'main' }} + TO_REF: ${{ github.event.pull_request.head.ref || 'develop' }} + run: python "${{ github.action_path }}/create_release_notes.py" diff --git a/.github/actions/release-notes/create_release_notes.py b/.github/actions/release-notes/create_release_notes.py new file mode 100644 index 00000000..22494f35 --- /dev/null +++ b/.github/actions/release-notes/create_release_notes.py @@ -0,0 +1,417 @@ +""" +Create a Notion release notes page from merge commits between two git refs. + +Extracts Linear issue keys (e.g. SORD-123, FOS-42) from merge commits, +fetches their titles via the Linear GraphQL API, categorizes them by +conventional commit prefix, and creates a formatted page in a Notion +release notes database. + +Usage (env vars): + LINEAR_API_KEY - Linear API token + NOTION_API_KEY - Notion integration token + VERSION - Full version string (e.g. 1.4.4.23588748828) + RELEASE_TYPE - Value stored in the "Mobile app release type" Notion property + PR_SOURCE - Go-to-prod PR number + REPO - GitHub repository (owner/name), used to build PR links + NOTION_DATABASE_ID - Notion database ID to create the release notes page into + LINEAR_WORKSPACE - Linear workspace slug, used for fallback issue URLs (default: smartway) + LINEAR_ISSUE_PATTERN - Regex used to detect Linear issue keys in commit messages (default: [A-Z]{2,10}-\\d+) + FROM_REF - Git ref to compare from (default: main) + TO_REF - Git ref to compare to (default: develop) +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import time +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from datetime import date + +LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql" +NOTION_API_URL = "https://api.notion.com/v1/pages" +NOTION_BLOCK_API_URL = "https://api.notion.com/v1/blocks" +NOTION_API_VERSION = "2022-06-28" +NOTION_BLOCK_LIMIT = 100 + + +@dataclass +class Issue: + issue_key: str + title: str + url: str + commit_subject: str + + +@dataclass +class CategorizedIssues: + features: list[Issue] = field(default_factory=list) + fixes: list[Issue] = field(default_factory=list) + tech: list[Issue] = field(default_factory=list) + other: list[Issue] = field(default_factory=list) + + +def git(*args: str) -> str: + result = subprocess.run( + ["git", *args], + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def extract_linear_keys_from_merges( + from_ref: str, to_ref: str, repo: str, linear_issue_pattern: str +) -> CategorizedIssues: + merge_log = git( + "log", "--merges", "--format=%H %s", + f"origin/{from_ref}..origin/{to_ref}", + ) + if not merge_log: + merge_log = git( + "log", "--merges", "--format=%H %s", + f"{from_ref}..{to_ref}", + ) + + categorized = CategorizedIssues() + seen: set[str] = set() + + for line in merge_log.splitlines(): + if not line.strip(): + continue + + commit_hash, message = line.split(" ", 1) + + first_commit = git( + "log", "--reverse", "--no-merges", "--format=%s", + f"{commit_hash}^1..{commit_hash}^2", + ) + first_line = first_commit.splitlines()[0] if first_commit else "" + + issue_keys = re.findall(linear_issue_pattern, message) + if not issue_keys: + if re.search(rf"from \S+/{re.escape(to_ref)}$", message): + continue + + pr_match = re.search(r"#(\d+)", message) + pr_url = ( + f"https://github.com/{repo}/pull/{pr_match.group(1)}" + if pr_match else "" + ) + title = first_line or message + categorized.other.append(Issue("", title, pr_url, "")) + continue + + for issue_key in issue_keys: + if issue_key in seen: + continue + seen.add(issue_key) + + if first_line.startswith("feat"): + categorized.features.append(Issue(issue_key, "", "", first_line)) + elif first_line.startswith("fix"): + categorized.fixes.append(Issue(issue_key, "", "", first_line)) + else: + categorized.tech.append(Issue(issue_key, "", "", first_line)) + + return categorized + + +def fetch_linear_issue(issue_key: str, api_key: str, linear_workspace: str) -> Issue: + query = json.dumps({ + "query": f'{{ issue(id: "{issue_key}") {{ title url }} }}' + }) + + req = urllib.request.Request( + LINEAR_GRAPHQL_URL, + data=query.encode(), + headers={ + "Authorization": api_key, + "Content-Type": "application/json", + }, + ) + + try: + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.loads(resp.read()) + + errors = data.get("errors") + if errors: + print(f"::warning::Linear GraphQL errors for {issue_key}: {errors}") + title = "" + url = "" + else: + issue_data = data.get("data", {}).get("issue") or {} + title = issue_data.get("title", "") + url = issue_data.get("url", "") + except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError) as exc: + print(f"::warning::Could not fetch Linear issue {issue_key}: {exc}") + title = "" + url = "" + + if not title: + title = issue_key + url = f"https://linear.app/{linear_workspace}/issue/{issue_key}" + + return Issue(issue_key, title, url, "") + + +def enrich_with_linear(categorized: CategorizedIssues, api_key: str, linear_workspace: str) -> None: + all_lists = [categorized.features, categorized.fixes, categorized.tech] + for issue_list in all_lists: + for issue in issue_list: + enriched = fetch_linear_issue(issue.issue_key, api_key, linear_workspace) + issue.title = enriched.title + issue.url = enriched.url + time.sleep(0.2) + + +def _heading(level: int, text: str) -> dict: + block_type = f"heading_{level}" + return { + "object": "block", + "type": block_type, + block_type: { + "rich_text": [{"type": "text", "text": {"content": text}}], + }, + } + + +def _bullet(issue: Issue) -> dict: + rich_text: list[dict] = [] + if issue.issue_key: + rich_text.append({ + "type": "text", + "text": {"content": issue.issue_key, "link": {"url": issue.url}}, + }) + rich_text.append({ + "type": "text", + "text": {"content": f": {issue.title}"}, + }) + elif issue.url: + rich_text.append({ + "type": "text", + "text": {"content": issue.title, "link": {"url": issue.url}}, + }) + else: + rich_text.append({ + "type": "text", + "text": {"content": issue.title}, + }) + if issue.commit_subject: + rich_text.append({ + "type": "text", + "text": {"content": f"\n{issue.commit_subject}"}, + "annotations": {"italic": True, "color": "gray"}, + }) + return { + "object": "block", + "type": "bulleted_list_item", + "bulleted_list_item": {"rich_text": rich_text}, + } + + +def build_section(emoji: str, title: str, issues: list[Issue]) -> list[dict]: + if not issues: + return [] + return [_heading(1, f"{emoji} {title}")] + [_bullet(issue) for issue in issues] + + +def build_notion_payload( + categorized: CategorizedIssues, + version: str, + release_type: str, + repo: str, + pr_source: str, + notion_database_id: str, + today: str, +) -> dict: + children: list[dict] = [] + children += build_section("✨", "Features", categorized.features) + children += build_section("\U0001f41b", "Fixes", categorized.fixes) + children += build_section("\U0001f527", "Technical Improvements", categorized.tech) + children += build_section("\U0001f4e6", "Other Changes", categorized.other) + + pr_url = f"https://github.com/{repo}/pull/{pr_source}" + children += [ + _heading(2, "Technical details"), + { + "object": "block", + "type": "bulleted_list_item", + "bulleted_list_item": { + "rich_text": [ + {"type": "text", "text": {"content": "Mobile app changes: "}}, + ], + "children": [ + { + "object": "block", + "type": "bulleted_list_item", + "bulleted_list_item": { + "rich_text": [ + { + "type": "text", + "text": { + "content": "Go to prod", + "link": {"url": pr_url}, + }, + }, + ], + }, + }, + ], + }, + }, + ] + + return { + "parent": {"database_id": notion_database_id}, + "properties": { + "Version": {"title": [{"type": "text", "text": {"content": version}}]}, + "Mobile app release type": {"select": {"name": release_type}}, + "Release date": {"date": {"start": today}}, + "Web plaftorm changes": {"checkbox": False}, + }, + "children": children, + } + + +def create_notion_page(payload: dict, api_key: str) -> tuple[str, str]: + body = json.dumps(payload).encode() + + req = urllib.request.Request( + NOTION_API_URL, + data=body, + method="POST", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "Notion-Version": NOTION_API_VERSION, + }, + ) + + try: + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.loads(resp.read()) + return data.get("id", ""), data.get("url", "") + except urllib.error.HTTPError as exc: + error_body = exc.read().decode() + print(f"::error::Failed to create Notion page (HTTP {exc.code})") + print("Payload sent:") + print(json.dumps(payload, indent=2)) + print("Response:") + try: + print(json.dumps(json.loads(error_body), indent=2)) + except json.JSONDecodeError: + print(error_body) + sys.exit(1) + + +def append_blocks_to_page(page_id: str, blocks: list[dict], api_key: str) -> None: + url = f"{NOTION_BLOCK_API_URL}/{page_id}/children" + body = json.dumps({"children": blocks}).encode() + + req = urllib.request.Request( + url, + data=body, + method="PATCH", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "Notion-Version": NOTION_API_VERSION, + }, + ) + + try: + with urllib.request.urlopen(req, timeout=30) as resp: + resp.read() + except urllib.error.HTTPError as exc: + error_body = exc.read().decode() + print(f"::error::Failed to append blocks to page {page_id} (HTTP {exc.code})") + try: + print(json.dumps(json.loads(error_body), indent=2)) + except json.JSONDecodeError: + print(error_body) + sys.exit(1) + + +def write_github_summary(version: str, release_type: str, today: str, page_url: str) -> None: + summary_path = os.environ.get("GITHUB_STEP_SUMMARY", "") + if not summary_path: + return + + with open(summary_path, "a") as f: + f.write("### Release notes created\n\n") + f.write(f"**Version**: {version}\n") + f.write(f"**Release type**: {release_type}\n") + f.write(f"**Date**: {today}\n\n") + f.write(f"[Open in Notion]({page_url})\n") + + +def write_github_output(page_id: str, page_url: str) -> None: + output_path = os.environ.get("GITHUB_OUTPUT", "") + if not output_path: + return + + with open(output_path, "a") as f: + f.write(f"page_id={page_id}\n") + f.write(f"page_url={page_url}\n") + + +def main() -> None: + linear_api_key = os.environ["LINEAR_API_KEY"] + notion_api_key = os.environ["NOTION_API_KEY"] + version = os.environ["VERSION"] + release_type = os.environ["RELEASE_TYPE"] + pr_source = os.environ["PR_SOURCE"] + repo = os.environ["REPO"] + notion_database_id = os.environ["NOTION_DATABASE_ID"] + linear_workspace = os.environ.get("LINEAR_WORKSPACE", "smartway") + linear_issue_pattern = os.environ.get("LINEAR_ISSUE_PATTERN", r"[A-Z]{2,10}-\d+") + from_ref = os.environ.get("FROM_REF", "main") + to_ref = os.environ.get("TO_REF", "develop") + today = date.today().isoformat() + + print(f"Extracting issues from merge commits between {from_ref} and {to_ref}...") + categorized = extract_linear_keys_from_merges(from_ref, to_ref, repo, linear_issue_pattern) + + print(f"Features: {[i.issue_key for i in categorized.features] or 'none'}") + print(f"Fixes: {[i.issue_key for i in categorized.fixes] or 'none'}") + print(f"Tech: {[i.issue_key for i in categorized.tech] or 'none'}") + print(f"Other: {[i.title for i in categorized.other] or 'none'}") + + total = len(categorized.features) + len(categorized.fixes) + len(categorized.tech) + if total == 0: + print(f"::warning::No Linear issues found in merge commits between {from_ref} and {to_ref}") + + print("Fetching Linear issue details...") + enrich_with_linear(categorized, linear_api_key, linear_workspace) + + print("Building Notion page...") + payload = build_notion_payload( + categorized, version, release_type, repo, pr_source, notion_database_id, today + ) + + all_children = payload.pop("children", []) + payload["children"] = all_children[:NOTION_BLOCK_LIMIT] + + page_id, page_url = create_notion_page(payload, notion_api_key) + + remaining = all_children[NOTION_BLOCK_LIMIT:] + for i in range(0, len(remaining), NOTION_BLOCK_LIMIT): + batch = remaining[i:i + NOTION_BLOCK_LIMIT] + print(f"Appending {len(batch)} additional blocks...") + append_blocks_to_page(page_id, batch, notion_api_key) + + print(f"::notice::Release notes created: {page_url}") + + write_github_summary(version, release_type, today, page_url) + write_github_output(page_id, page_url) + + +if __name__ == "__main__": + main() From 8bdfa7ec4857b6b0902d4ff55740773f8028ebad Mon Sep 17 00:00:00 2001 From: ulricden Date: Fri, 17 Jul 2026 15:57:18 +0200 Subject: [PATCH 2/6] fix: detect Linear keys in conventional-commit scopes, case-insensitively Ticket references like feat(SORD-1234) or test(sord-1232) only appear in the squashed commit's scope, not the merge commit subject, and are sometimes written in lowercase; search both and normalize to uppercase. Co-Authored-By: Claude Sonnet 5 --- .github/actions/release-notes/action.yml | 2 +- .github/actions/release-notes/create_release_notes.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/actions/release-notes/action.yml b/.github/actions/release-notes/action.yml index 57fbfc45..4bc27f50 100644 --- a/.github/actions/release-notes/action.yml +++ b/.github/actions/release-notes/action.yml @@ -32,7 +32,7 @@ inputs: required: false default: "smartway" linear_issue_pattern: - description: "Regex used to detect Linear issue keys in commit messages. Defaults to matching any team prefix (e.g. SORD-123, FOS-42); restrict it (e.g. 'SORD-\\d+') to only match specific teams" + description: "Regex used to detect Linear issue keys in merge commit subjects and conventional-commit scopes (e.g. 'feat(SORD-1234): ...'), matched case-insensitively. Defaults to matching any team prefix (e.g. SORD-123, FOS-42); restrict it (e.g. 'SORD-\\d+') to only match specific teams" required: false default: '[A-Z]{2,10}-\d+' diff --git a/.github/actions/release-notes/create_release_notes.py b/.github/actions/release-notes/create_release_notes.py index 22494f35..5c8aa191 100644 --- a/.github/actions/release-notes/create_release_notes.py +++ b/.github/actions/release-notes/create_release_notes.py @@ -93,7 +93,10 @@ def extract_linear_keys_from_merges( ) first_line = first_commit.splitlines()[0] if first_commit else "" - issue_keys = re.findall(linear_issue_pattern, message) + # The issue key can show up in the merge commit subject (e.g. a + # branch name like "feature/SORD-1234-do-thing") or in the squashed + # commit's own conventional-commit scope (e.g. "feat(sord-1234): ..."). + issue_keys = re.findall(linear_issue_pattern, f"{message} {first_line}", re.IGNORECASE) if not issue_keys: if re.search(rf"from \S+/{re.escape(to_ref)}$", message): continue @@ -108,6 +111,7 @@ def extract_linear_keys_from_merges( continue for issue_key in issue_keys: + issue_key = issue_key.upper() if issue_key in seen: continue seen.add(issue_key) From 955979e14f53ec4c5f520c323ac95c27724f08f4 Mon Sep 17 00:00:00 2001 From: ulricden Date: Fri, 17 Jul 2026 16:15:02 +0200 Subject: [PATCH 3/6] fix: capture Linear tickets from squash-merged commits The merge-walking extractor missed tickets from PRs squash- or rebase-merged into develop (no merge commit of their own), where the ticket lives in the commit's conventional-commit scope (e.g. "feat(FWMS-2300): ..."). Those commits ended up in "Other" or were dropped entirely. Now scan every non-merge commit in the range to categorize ticketed commits, and keep a merge pass for branch-name-only tickets (e.g. "from .../SORD-123") and for grouping genuinely ticket-less PRs into "Other". Lower-case keys are matched only inside a conventional-commit scope to avoid false positives on branch names like "renovate/appium-3.x". Co-Authored-By: Claude Opus 4.8 (1M context) --- .../release-notes/create_release_notes.py | 103 ++++++++++++------ 1 file changed, 72 insertions(+), 31 deletions(-) diff --git a/.github/actions/release-notes/create_release_notes.py b/.github/actions/release-notes/create_release_notes.py index 5c8aa191..138e8a86 100644 --- a/.github/actions/release-notes/create_release_notes.py +++ b/.github/actions/release-notes/create_release_notes.py @@ -1,9 +1,11 @@ """ -Create a Notion release notes page from merge commits between two git refs. +Create a Notion release notes page from the commits between two git refs. -Extracts Linear issue keys (e.g. SORD-123, FOS-42) from merge commits, -fetches their titles via the Linear GraphQL API, categorizes them by -conventional commit prefix, and creates a formatted page in a Notion +Extracts Linear issue keys (e.g. SORD-123, FOS-42) from the commits in the +range — both individual commit subjects (including squash-merged commits +with no merge commit of their own, e.g. "feat(FWMS-2300): ...") and PR +branch names — fetches their titles via the Linear GraphQL API, categorizes +them by conventional commit prefix, and creates a formatted page in a Notion release notes database. Usage (env vars): @@ -65,39 +67,84 @@ def git(*args: str) -> str: return result.stdout.strip() -def extract_linear_keys_from_merges( +def find_issue_keys(text: str, linear_issue_pattern: str) -> list[str]: + """Find Linear issue keys in a commit subject. + + Keys written in upper case are matched anywhere in the subject (branch + names, free text, ...). Keys are also matched case-insensitively inside a + conventional-commit scope, e.g. "feat(sord-1234): ..." — this is where + lower-case keys tend to appear, and scoping the case-insensitive match + there avoids false positives on lower-case "word-number" branch names + such as "renovate/appium-3.x". + """ + keys = list(re.findall(linear_issue_pattern, text)) + scope = re.match(r"^[a-zA-Z]+(?:\(([^)]*)\))?!?:", text) + if scope and scope.group(1): + keys += re.findall(linear_issue_pattern, scope.group(1), re.IGNORECASE) + return list(dict.fromkeys(k.upper() for k in keys)) + + +def _categorize(categorized: CategorizedIssues, issue_key: str, subject: str) -> None: + if subject.startswith("feat"): + categorized.features.append(Issue(issue_key, "", "", subject)) + elif subject.startswith("fix"): + categorized.fixes.append(Issue(issue_key, "", "", subject)) + else: + categorized.tech.append(Issue(issue_key, "", "", subject)) + + +def extract_categorized_issues( from_ref: str, to_ref: str, repo: str, linear_issue_pattern: str ) -> CategorizedIssues: - merge_log = git( - "log", "--merges", "--format=%H %s", - f"origin/{from_ref}..origin/{to_ref}", - ) - if not merge_log: - merge_log = git( - "log", "--merges", "--format=%H %s", - f"{from_ref}..{to_ref}", - ) + def log(*args: str) -> str: + out = git("log", *args, f"origin/{from_ref}..origin/{to_ref}") + if not out: + out = git("log", *args, f"{from_ref}..{to_ref}") + return out categorized = CategorizedIssues() seen: set[str] = set() + # Pass 1 — walk every non-merge commit. This is the only way to catch + # tickets from PRs that were squash- or rebase-merged (no merge commit), + # which is the common case: the ticket lives in the commit's own + # conventional-commit scope, e.g. "feat(FWMS-2300): ...". + commit_log = log("--no-merges", "--format=%s") + for subject in commit_log.splitlines(): + if not subject.strip(): + continue + for issue_key in find_issue_keys(subject, linear_issue_pattern): + if issue_key in seen: + continue + seen.add(issue_key) + _categorize(categorized, issue_key, subject) + + # Pass 2 — walk merge commits. Handles tickets that only appear in a PR + # branch name (e.g. "from ZeroGachis/SORD-123-foo") with no ticket in the + # commit subjects, and collects genuinely ticket-less PRs into "Other". + merge_log = log("--merges", "--format=%H %s") for line in merge_log.splitlines(): if not line.strip(): continue commit_hash, message = line.split(" ", 1) - first_commit = git( + pr_commits = git( "log", "--reverse", "--no-merges", "--format=%s", f"{commit_hash}^1..{commit_hash}^2", + ).splitlines() + first_line = pr_commits[0] if pr_commits else "" + + # Keys from the branch name / merge subject (upper case only: a + # lower-case "word-number" here is almost always a package version, + # not a ticket). + message_keys = [k.upper() for k in re.findall(linear_issue_pattern, message)] + + pr_has_key = bool(message_keys) or any( + find_issue_keys(subject, linear_issue_pattern) for subject in pr_commits ) - first_line = first_commit.splitlines()[0] if first_commit else "" - # The issue key can show up in the merge commit subject (e.g. a - # branch name like "feature/SORD-1234-do-thing") or in the squashed - # commit's own conventional-commit scope (e.g. "feat(sord-1234): ..."). - issue_keys = re.findall(linear_issue_pattern, f"{message} {first_line}", re.IGNORECASE) - if not issue_keys: + if not pr_has_key: if re.search(rf"from \S+/{re.escape(to_ref)}$", message): continue @@ -110,18 +157,12 @@ def extract_linear_keys_from_merges( categorized.other.append(Issue("", title, pr_url, "")) continue - for issue_key in issue_keys: - issue_key = issue_key.upper() + # Branch-name-only tickets not already surfaced by a commit subject. + for issue_key in message_keys: if issue_key in seen: continue seen.add(issue_key) - - if first_line.startswith("feat"): - categorized.features.append(Issue(issue_key, "", "", first_line)) - elif first_line.startswith("fix"): - categorized.fixes.append(Issue(issue_key, "", "", first_line)) - else: - categorized.tech.append(Issue(issue_key, "", "", first_line)) + _categorize(categorized, issue_key, first_line) return categorized @@ -381,7 +422,7 @@ def main() -> None: today = date.today().isoformat() print(f"Extracting issues from merge commits between {from_ref} and {to_ref}...") - categorized = extract_linear_keys_from_merges(from_ref, to_ref, repo, linear_issue_pattern) + categorized = extract_categorized_issues(from_ref, to_ref, repo, linear_issue_pattern) print(f"Features: {[i.issue_key for i in categorized.features] or 'none'}") print(f"Fixes: {[i.issue_key for i in categorized.fixes] or 'none'}") From 4f21b79d84f49757ce10741f39e49a001b8d455a Mon Sep 17 00:00:00 2001 From: ulricden Date: Fri, 17 Jul 2026 16:27:39 +0200 Subject: [PATCH 4/6] fix: catch lower-case branch-name tickets, categorize by branch type PRs whose branch names carry the ticket in lower case (e.g. "fix/fwms-2310", "feat/fwms-2326-...", "fix/mas-710") were dropped into "Other" because the merge-message pass only matched upper-case keys. Match branch names case-insensitively, but: - require a token boundary so mid-word matches like "to-56" in "update-expo-to-56" are rejected; - skip bot branches (renovate/, dependabot/) whose package names ("appium-3.x") look like tickets; - restrict lower-case commit-subject matches to the conventional-commit scope so prose like "utf-8" is not mistaken for a ticket. Branch-only tickets are now categorized by the branch type prefix (feat/ -> Features, fix/ -> Fixes) instead of the PR's first commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../release-notes/create_release_notes.py | 75 +++++++++++++++---- 1 file changed, 61 insertions(+), 14 deletions(-) diff --git a/.github/actions/release-notes/create_release_notes.py b/.github/actions/release-notes/create_release_notes.py index 138e8a86..10dc3d07 100644 --- a/.github/actions/release-notes/create_release_notes.py +++ b/.github/actions/release-notes/create_release_notes.py @@ -67,21 +67,51 @@ def git(*args: str) -> str: return result.stdout.strip() +def _boundary_keys(text: str, linear_issue_pattern: str, ignorecase: bool) -> list[str]: + """Return pattern matches whose key starts a fresh token. + + A match is rejected when the preceding character is a letter, digit or + hyphen, so a key must sit at a real token boundary (after "/", "(", ":", + whitespace, ...). This drops mid-word matches such as the "to-56" inside + "update-expo-to-56", while keeping "fix/fwms-2310" or "feat(FWMS-2303)". + """ + flags = re.IGNORECASE if ignorecase else 0 + keys: list[str] = [] + for match in re.finditer(linear_issue_pattern, text, flags): + start = match.start() + if start > 0 and (text[start - 1].isalnum() or text[start - 1] == "-"): + continue + keys.append(match.group(0).upper()) + return keys + + def find_issue_keys(text: str, linear_issue_pattern: str) -> list[str]: """Find Linear issue keys in a commit subject. - Keys written in upper case are matched anywhere in the subject (branch - names, free text, ...). Keys are also matched case-insensitively inside a - conventional-commit scope, e.g. "feat(sord-1234): ..." — this is where - lower-case keys tend to appear, and scoping the case-insensitive match - there avoids false positives on lower-case "word-number" branch names - such as "renovate/appium-3.x". + Upper-case keys are matched anywhere in the subject; lower-case keys are + only matched inside a conventional-commit scope, e.g. "feat(sord-1234): + ...", where they reliably appear — restricting the case-insensitive match + there avoids false positives on lower-case "word-number" prose such as + "use utf-8 encoding". """ - keys = list(re.findall(linear_issue_pattern, text)) + keys = _boundary_keys(text, linear_issue_pattern, ignorecase=False) scope = re.match(r"^[a-zA-Z]+(?:\(([^)]*)\))?!?:", text) if scope and scope.group(1): - keys += re.findall(linear_issue_pattern, scope.group(1), re.IGNORECASE) - return list(dict.fromkeys(k.upper() for k in keys)) + keys += _boundary_keys(scope.group(1), linear_issue_pattern, ignorecase=True) + return list(dict.fromkeys(keys)) + + +def find_branch_keys(message: str, linear_issue_pattern: str) -> list[str]: + """Find Linear issue keys in a merge commit subject (PR branch name). + + Branch names carry keys as path segments (e.g. "from .../fix/fwms-2310"), + often in lower case, so match case-insensitively — but skip bot branches + ("renovate/...", "dependabot/...") whose package names ("appium-3.x") + would otherwise look like tickets. + """ + if re.search(r"(?:^|[/\s])(?:renovate|dependabot)/", message, re.IGNORECASE): + return [] + return list(dict.fromkeys(_boundary_keys(message, linear_issue_pattern, ignorecase=True))) def _categorize(categorized: CategorizedIssues, issue_key: str, subject: str) -> None: @@ -93,6 +123,17 @@ def _categorize(categorized: CategorizedIssues, issue_key: str, subject: str) -> categorized.tech.append(Issue(issue_key, "", "", subject)) +def branch_type(message: str) -> str: + """Conventional type of a PR branch, e.g. "feat" for ".../feat/fwms-2326". + + Returns "feat"/"fix"/... when the branch is namespaced with a type + segment, else "". Used to categorize a ticket found only in a branch name, + whose PR has no conventional-commit subject to key off of. + """ + match = re.search(r"from \S+?/([a-zA-Z]+)/", message) + return match.group(1).lower() if match else "" + + def extract_categorized_issues( from_ref: str, to_ref: str, repo: str, linear_issue_pattern: str ) -> CategorizedIssues: @@ -135,10 +176,8 @@ def log(*args: str) -> str: ).splitlines() first_line = pr_commits[0] if pr_commits else "" - # Keys from the branch name / merge subject (upper case only: a - # lower-case "word-number" here is almost always a package version, - # not a ticket). - message_keys = [k.upper() for k in re.findall(linear_issue_pattern, message)] + # Keys from the branch name / merge subject. + message_keys = find_branch_keys(message, linear_issue_pattern) pr_has_key = bool(message_keys) or any( find_issue_keys(subject, linear_issue_pattern) for subject in pr_commits @@ -158,11 +197,19 @@ def log(*args: str) -> str: continue # Branch-name-only tickets not already surfaced by a commit subject. + # Prefer the branch's own type (feat/fwms-2326 -> Features); fall back + # to the PR's first commit when the branch carries no type segment. + btype = branch_type(message) for issue_key in message_keys: if issue_key in seen: continue seen.add(issue_key) - _categorize(categorized, issue_key, first_line) + if btype in ("feat", "feature"): + categorized.features.append(Issue(issue_key, "", "", first_line)) + elif btype == "fix": + categorized.fixes.append(Issue(issue_key, "", "", first_line)) + else: + _categorize(categorized, issue_key, first_line) return categorized From 8f98c3f875f106c6430bcb51939af083a9d91a16 Mon Sep 17 00:00:00 2001 From: ulricden Date: Fri, 17 Jul 2026 16:48:05 +0200 Subject: [PATCH 5/6] fix: treat linear_issue_pattern as a team prefix, not a full regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passing "FWMS" (no -) made the whole regex match the bare string "FWMS", collapsing every ticket into one bogus key that 404'd on Linear ("Entity not found: Issue") and rendered as a dead link. The input is now a team prefix (or alternation, e.g. "FWMS" / "FWMS|MAS"); the action appends "-\d+" itself to form the Linear identifier — mirroring smartapp's hardcoded "SORD-\d+". The default prefix "[A-Z]{2,10}" still matches any team. A guard also drops any extracted key that isn't a canonical PREFIX-NUMBER, so a misconfigured input can no longer emit a numberless key. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/actions/release-notes/action.yml | 4 ++-- .../release-notes/create_release_notes.py | 20 +++++++++++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/actions/release-notes/action.yml b/.github/actions/release-notes/action.yml index 4bc27f50..ca296998 100644 --- a/.github/actions/release-notes/action.yml +++ b/.github/actions/release-notes/action.yml @@ -32,9 +32,9 @@ inputs: required: false default: "smartway" linear_issue_pattern: - description: "Regex used to detect Linear issue keys in merge commit subjects and conventional-commit scopes (e.g. 'feat(SORD-1234): ...'), matched case-insensitively. Defaults to matching any team prefix (e.g. SORD-123, FOS-42); restrict it (e.g. 'SORD-\\d+') to only match specific teams" + description: "Linear team prefix(es) of the issues to look for, e.g. 'FWMS' (matches FWMS-1234) or an alternation 'FWMS|MAS'. The '-' suffix is appended automatically. Matched case-insensitively in commit scopes and PR branch names. Leave at the default to match any team." required: false - default: '[A-Z]{2,10}-\d+' + default: '[A-Z]{2,10}' outputs: page_url: diff --git a/.github/actions/release-notes/create_release_notes.py b/.github/actions/release-notes/create_release_notes.py index 10dc3d07..13e3dc3a 100644 --- a/.github/actions/release-notes/create_release_notes.py +++ b/.github/actions/release-notes/create_release_notes.py @@ -17,7 +17,9 @@ REPO - GitHub repository (owner/name), used to build PR links NOTION_DATABASE_ID - Notion database ID to create the release notes page into LINEAR_WORKSPACE - Linear workspace slug, used for fallback issue URLs (default: smartway) - LINEAR_ISSUE_PATTERN - Regex used to detect Linear issue keys in commit messages (default: [A-Z]{2,10}-\\d+) + LINEAR_ISSUE_PATTERN - Linear team prefix(es) to look for; the "-" suffix is + appended automatically. E.g. "FWMS" matches FWMS-1234, "FWMS|MAS" + matches both. Default "[A-Z]{2,10}" matches any team. FROM_REF - Git ref to compare from (default: main) TO_REF - Git ref to compare to (default: develop) """ @@ -81,7 +83,13 @@ def _boundary_keys(text: str, linear_issue_pattern: str, ignorecase: bool) -> li start = match.start() if start > 0 and (text[start - 1].isalnum() or text[start - 1] == "-"): continue - keys.append(match.group(0).upper()) + key = match.group(0).upper() + # Guard: only keep canonical Linear identifiers (PREFIX-NUMBER), so a + # misconfigured pattern can never emit a numberless key that would 404 + # on Linear and render as a dead link. + if not re.fullmatch(r"[A-Z][A-Z0-9]{1,9}-\d+", key): + continue + keys.append(key) return keys @@ -463,13 +471,17 @@ def main() -> None: repo = os.environ["REPO"] notion_database_id = os.environ["NOTION_DATABASE_ID"] linear_workspace = os.environ.get("LINEAR_WORKSPACE", "smartway") - linear_issue_pattern = os.environ.get("LINEAR_ISSUE_PATTERN", r"[A-Z]{2,10}-\d+") + # The input is a team prefix (or alternation, e.g. "FWMS" / "FWMS|MAS"); + # the "-" that makes it a Linear identifier is appended here. + issue_prefix = os.environ.get("LINEAR_ISSUE_PATTERN") or r"[A-Z]{2,10}" + issue_key_regex = rf"(?:{issue_prefix})-\d+" from_ref = os.environ.get("FROM_REF", "main") to_ref = os.environ.get("TO_REF", "develop") today = date.today().isoformat() print(f"Extracting issues from merge commits between {from_ref} and {to_ref}...") - categorized = extract_categorized_issues(from_ref, to_ref, repo, linear_issue_pattern) + print(f"Linear issue key regex: {issue_key_regex}") + categorized = extract_categorized_issues(from_ref, to_ref, repo, issue_key_regex) print(f"Features: {[i.issue_key for i in categorized.features] or 'none'}") print(f"Fixes: {[i.issue_key for i in categorized.fixes] or 'none'}") From fd72d0edce387c744d767b8376da49556e431c01 Mon Sep 17 00:00:00 2001 From: Josselin TILLAY Date: Fri, 31 Jul 2026 10:36:30 +0200 Subject: [PATCH 6/6] feat(ReleaseNote): Add project --- .github/actions/release-notes/action.yml | 4 +++ .../release-notes/create_release_notes.py | 31 +++++++++++++------ 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/.github/actions/release-notes/action.yml b/.github/actions/release-notes/action.yml index ca296998..e8fc7ea2 100644 --- a/.github/actions/release-notes/action.yml +++ b/.github/actions/release-notes/action.yml @@ -15,6 +15,9 @@ inputs: release_type: description: "Release type (value stored in the 'Mobile app release type' Notion property)" required: true + project: + description: "Project the release notes come from, e.g. 'fwms-app', 'smartapp', 'tasking-app' (value stored in the 'Project' Notion property)" + required: true pr_source: description: "Go-to-prod PR number, used to build the 'Go to prod' link in the release notes" required: true @@ -55,6 +58,7 @@ runs: NOTION_API_KEY: ${{ inputs.notion_api_key }} VERSION: ${{ inputs.version }} RELEASE_TYPE: ${{ inputs.release_type }} + PROJECT: ${{ inputs.project }} PR_SOURCE: ${{ inputs.pr_source }} NOTION_DATABASE_ID: ${{ inputs.notion_database_id }} LINEAR_WORKSPACE: ${{ inputs.linear_workspace }} diff --git a/.github/actions/release-notes/create_release_notes.py b/.github/actions/release-notes/create_release_notes.py index 13e3dc3a..ac400c11 100644 --- a/.github/actions/release-notes/create_release_notes.py +++ b/.github/actions/release-notes/create_release_notes.py @@ -13,6 +13,8 @@ NOTION_API_KEY - Notion integration token VERSION - Full version string (e.g. 1.4.4.23588748828) RELEASE_TYPE - Value stored in the "Mobile app release type" Notion property + PROJECT - Project the release notes come from (e.g. fwms-app, smartapp, + tasking-app), stored in the "Project" Notion property PR_SOURCE - Go-to-prod PR number REPO - GitHub repository (owner/name), used to build PR links NOTION_DATABASE_ID - Notion database ID to create the release notes page into @@ -326,6 +328,7 @@ def build_notion_payload( categorized: CategorizedIssues, version: str, release_type: str, + project: str, repo: str, pr_source: str, notion_database_id: str, @@ -368,14 +371,20 @@ def build_notion_payload( }, ] + properties: dict = { + "Version": {"title": [{"type": "text", "text": {"content": version}}]}, + "Mobile app release type": {"select": {"name": release_type}}, + "Release date": {"date": {"start": today}}, + "Web plaftorm changes": {"checkbox": False}, + } + # An empty select name is rejected by Notion, so leave the property unset + # when no project was provided. + if project: + properties["Project"] = {"select": {"name": project}} + return { "parent": {"database_id": notion_database_id}, - "properties": { - "Version": {"title": [{"type": "text", "text": {"content": version}}]}, - "Mobile app release type": {"select": {"name": release_type}}, - "Release date": {"date": {"start": today}}, - "Web plaftorm changes": {"checkbox": False}, - }, + "properties": properties, "children": children, } @@ -439,7 +448,9 @@ def append_blocks_to_page(page_id: str, blocks: list[dict], api_key: str) -> Non sys.exit(1) -def write_github_summary(version: str, release_type: str, today: str, page_url: str) -> None: +def write_github_summary( + version: str, release_type: str, project: str, today: str, page_url: str +) -> None: summary_path = os.environ.get("GITHUB_STEP_SUMMARY", "") if not summary_path: return @@ -448,6 +459,7 @@ def write_github_summary(version: str, release_type: str, today: str, page_url: f.write("### Release notes created\n\n") f.write(f"**Version**: {version}\n") f.write(f"**Release type**: {release_type}\n") + f.write(f"**Project**: {project}\n") f.write(f"**Date**: {today}\n\n") f.write(f"[Open in Notion]({page_url})\n") @@ -467,6 +479,7 @@ def main() -> None: notion_api_key = os.environ["NOTION_API_KEY"] version = os.environ["VERSION"] release_type = os.environ["RELEASE_TYPE"] + project = os.environ.get("PROJECT", "") pr_source = os.environ["PR_SOURCE"] repo = os.environ["REPO"] notion_database_id = os.environ["NOTION_DATABASE_ID"] @@ -497,7 +510,7 @@ def main() -> None: print("Building Notion page...") payload = build_notion_payload( - categorized, version, release_type, repo, pr_source, notion_database_id, today + categorized, version, release_type, project, repo, pr_source, notion_database_id, today ) all_children = payload.pop("children", []) @@ -513,7 +526,7 @@ def main() -> None: print(f"::notice::Release notes created: {page_url}") - write_github_summary(version, release_type, today, page_url) + write_github_summary(version, release_type, project, today, page_url) write_github_output(page_id, page_url)