-
-
Notifications
You must be signed in to change notification settings - Fork 184
meta: Add hook to auto-add Linear issue links #1131
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| --- | ||
| name: commit | ||
| description: Use this skill when asked to create or amend a commit. | ||
| --- | ||
|
|
||
| # Commit | ||
|
|
||
| Use this skill whenever creating or amending a commit. | ||
|
|
||
| ## 1) Fetch and follow official commit guidelines | ||
|
|
||
| Run: | ||
|
|
||
| ```bash | ||
| scripts/fetch-commit-guidelines.sh | ||
| ``` | ||
|
|
||
| Use that output as the source of truth for commit format/rules. | ||
|
|
||
| **Exception:** Do not **manually wrap lines** or **enforce maximum line length**, ignore any instructions to the contrary. | ||
|
|
||
| ## 2) Write the commit body for maintainers | ||
|
|
||
| Commit messages are reused as PR descriptions. Therefore, write commit messages keeping in mind that the primary audiences are human code reviewers and future maintainers. Optimize for skimmability while retaining sufficient context around changes, but do not repeat context that is easily inferred from the changes themselves, linked issues, or background information that mainters with at least a basic familiarity of the codebase would possess. | ||
|
|
||
| Some tips: | ||
| - include brief context for why the change is needed | ||
| - include why this approach was chosen (when relevant) | ||
| - include links to relevant sources/issues/docs when useful | ||
| - be concise, human, and specific | ||
| - assume reviewers will skim the linked issue; do not restate it in depth | ||
|
|
||
| Commit messages use Markdown formatting. For example, use backticks for technical literals, inline links for URLs, and lists where useful. | ||
|
|
||
| When committing, you should use heredoc format to preserve newlines and other formatting. | ||
|
|
||
| ## 3) Append Commit Footer | ||
|
|
||
| If a commit is related to a GitHub issue, this must be noted in a footer. | ||
|
|
||
| These footers must be placed on their own lines. The footer looks like the following: | ||
|
|
||
| ``` | ||
| [keyword] #[issue-id] | ||
| ``` | ||
|
|
||
| When the issue is in a different repo, use `[keyword] [repo]#[issue-id]` or, if the repo belongs to a different owner, `[keyword] [owner]/[repo]#[issue-id]`. | ||
|
|
||
| The keywords "Closes", "Fixes" and "Resolves" indicate that the commit fully addresses the issue. Merging a pull request containing such a commit will close the referenced issue. | ||
|
|
||
| The keywords "References", "Related to", and "Contributes to" may be used to indicate a relation to the issue, when the issue is not fully addressed by the commit. The issue will not be auto-closed upon merge. | ||
|
|
||
| One commit may contain zero or more footers; make sure all related issues you are aware of have a corresponding footer. | ||
|
|
||
| A pre-commit hook will take care of linking Linear issues, where applicable. Do not manually add these links, or use any format other than what is described here. You need to follow this precise format so that the pre-commit hook can work properly. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| #!/usr/bin/env bash | ||
| set -euo pipefail | ||
|
|
||
| URL="https://develop.sentry.dev/engineering-practices/commit-messages.md" | ||
| curl -fsSL "$URL" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| repos: | ||
| - repo: local | ||
| hooks: | ||
| - id: expand-github-linear-footer | ||
| name: Expand GitHub/Linear commit footer | ||
| entry: scripts/commit-msg-expand-issues.py | ||
| language: script | ||
| stages: [commit-msg] |
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This script is admittedly 100% vibe-coded 🤖 I have skimmed the script, and it looks reasonable, but did not fully read it. In my limited testing, it appears to work. Given that this is only used as a |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,244 @@ | ||
| #!/usr/bin/env python3 | ||
| """Expand GitHub issue commit footers and add Linear footers when available.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import re | ||
| import subprocess | ||
| import sys | ||
| from dataclasses import dataclass | ||
| from pathlib import Path | ||
| from typing import Any | ||
|
|
||
| FOOTER_RE = re.compile( | ||
| r"^(?P<prefix>\s*)(?P<keyword>\w+)\s+" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Regex fails to match multi-word keywords documented in SKILL.mdMedium Severity
Additional Locations (1)Reviewed by Cursor Bugbot for commit 80c71ac. Configure here. |
||
| r"(?P<display>(?:(?P<owner>[A-Za-z0-9_.-]+)/)?(?:(?P<repo>[A-Za-z0-9_.-]+))?#(?P<issue>[1-9][0-9]*))" | ||
| r"(?P<suffix>\s*)$" | ||
| ) | ||
| LINEAR_LINKBACK_AUTHORS = {"linear", "linear-code"} | ||
| LINEAR_LINKBACK_MARKERS = ("linear-linkback", "linear linkback") | ||
|
|
||
| LINEAR_URL_RE = re.compile( | ||
| r"(?P<url>https://linear\.app/[^\s<>)\]\"']*/issue/(?P<id>[^/\s<>)\]\"']+)[^\s<>)\]\"']*)" | ||
| ) | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class Match: | ||
| line_index: int | ||
| prefix: str | ||
| keyword: str | ||
| display: str | ||
| owner: str | None | ||
| repo: str | None | ||
| issue: str | ||
| suffix: str | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class IssueInfo: | ||
| url: str | ||
| linear_id: str | None = None | ||
| linear_url: str | None = None | ||
|
|
||
|
|
||
| def warn(message: str) -> None: | ||
| print(f"commit-msg-expand-issues: warning: {message}", file=sys.stderr) | ||
|
|
||
|
|
||
| def run_gh(args: list[str]) -> tuple[dict[str, Any] | None, str | None]: | ||
| try: | ||
| result = subprocess.run( | ||
| ["gh", *args], | ||
| check=False, | ||
| capture_output=True, | ||
| encoding="utf-8", | ||
| ) | ||
| except FileNotFoundError: | ||
| return None, "gh was not found" | ||
| except OSError as exc: | ||
| return None, f"failed to run gh: {exc}" | ||
|
|
||
| if result.returncode != 0: | ||
| detail = (result.stderr or result.stdout).strip() | ||
| return None, detail or f"gh exited with status {result.returncode}" | ||
|
|
||
| try: | ||
| return json.loads(result.stdout), None | ||
| except json.JSONDecodeError as exc: | ||
| return None, f"failed to parse gh output: {exc}" | ||
|
|
||
|
|
||
| def run_gh_text(args: list[str]) -> tuple[str | None, str | None]: | ||
| try: | ||
| result = subprocess.run( | ||
| ["gh", *args], | ||
| check=False, | ||
| capture_output=True, | ||
| encoding="utf-8", | ||
| ) | ||
| except FileNotFoundError: | ||
| return None, "gh was not found" | ||
| except OSError as exc: | ||
| return None, f"failed to run gh: {exc}" | ||
|
|
||
| if result.returncode != 0: | ||
| detail = (result.stderr or result.stdout).strip() | ||
| return None, detail or f"gh exited with status {result.returncode}" | ||
| return result.stdout.strip(), None | ||
|
|
||
|
|
||
| def current_repo() -> tuple[str, str] | None: | ||
| name_with_owner, error = run_gh_text( | ||
| ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"] | ||
| ) | ||
| if error is not None: | ||
| warn(f"could not resolve current repository: {error}") | ||
| return None | ||
|
|
||
| if not name_with_owner or "/" not in name_with_owner: | ||
| warn("could not resolve current repository: unexpected gh output") | ||
| return None | ||
|
|
||
| owner, repo = name_with_owner.split("/", 1) | ||
| return owner, repo | ||
|
|
||
|
|
||
| def is_linear_linkback_comment(comment: dict[str, Any]) -> bool: | ||
| author = comment.get("author") | ||
| if not isinstance(author, dict) or author.get("login") not in LINEAR_LINKBACK_AUTHORS: | ||
| return False | ||
|
|
||
| body = comment.get("body") | ||
| if not isinstance(body, str): | ||
| return False | ||
|
|
||
| normalized_body = body.lower() | ||
| return any(marker in normalized_body for marker in LINEAR_LINKBACK_MARKERS) | ||
|
|
||
|
|
||
| def find_linear_link(issue: dict[str, Any]) -> tuple[str, str] | None: | ||
| comments = issue.get("comments") or [] | ||
| if not isinstance(comments, list): | ||
| return None | ||
|
|
||
| for comment in comments: | ||
| if not isinstance(comment, dict) or not is_linear_linkback_comment(comment): | ||
| continue | ||
|
|
||
| body = comment["body"] | ||
| url_match = LINEAR_URL_RE.search(body) | ||
| if not url_match: | ||
| continue | ||
|
|
||
| return url_match.group("id"), url_match.group("url") | ||
| return None | ||
|
|
||
|
|
||
| def fetch_issue(owner_repo: str, issue_number: str) -> IssueInfo | None: | ||
| result, error = run_gh( | ||
| ["issue", "view", issue_number, "-R", owner_repo, "--json", "number,url,comments"] | ||
| ) | ||
| if error is not None: | ||
| warn(f"could not fetch {owner_repo}#{issue_number}: {error}") | ||
| return None | ||
| if not isinstance(result, dict) or not isinstance(result.get("url"), str): | ||
| warn(f"could not fetch {owner_repo}#{issue_number}: unexpected gh output") | ||
| return None | ||
|
|
||
| linear = find_linear_link(result) | ||
| if linear is None: | ||
| return IssueInfo(url=result["url"]) | ||
| linear_id, linear_url = linear | ||
| return IssueInfo(url=result["url"], linear_id=linear_id, linear_url=linear_url) | ||
|
|
||
|
|
||
| def collect_matches(lines: list[str]) -> list[Match]: | ||
| matches: list[Match] = [] | ||
| for index, line in enumerate(lines): | ||
| stripped_newline = line.removesuffix("\n") | ||
| match = FOOTER_RE.match(stripped_newline) | ||
| if match is None: | ||
| continue | ||
| matches.append( | ||
| Match( | ||
| line_index=index, | ||
| prefix=match.group("prefix"), | ||
| keyword=match.group("keyword"), | ||
| display=match.group("display"), | ||
| owner=match.group("owner"), | ||
| repo=match.group("repo"), | ||
| issue=match.group("issue"), | ||
| suffix=match.group("suffix"), | ||
| ) | ||
| ) | ||
| return matches | ||
|
|
||
|
|
||
| def resolve_owner_repo(match: Match, current_owner: str, current_repo_name: str) -> str: | ||
| if match.owner is not None and match.repo is not None: | ||
| return f"{match.owner}/{match.repo}" | ||
| if match.repo is not None: | ||
| return f"{current_owner}/{match.repo}" | ||
| return f"{current_owner}/{current_repo_name}" | ||
|
|
||
|
|
||
| def process_message(path: Path) -> None: | ||
| try: | ||
| lines = path.read_text(encoding="utf-8").splitlines(keepends=True) | ||
| except OSError as exc: | ||
| warn(f"could not read commit message: {exc}") | ||
| return | ||
|
|
||
| matches = collect_matches(lines) | ||
| if not matches: | ||
| return | ||
|
|
||
| repo = current_repo() | ||
| if repo is None: | ||
| return | ||
| current_owner, current_repo_name = repo | ||
|
|
||
| issue_cache: dict[tuple[str, str], IssueInfo | None] = {} | ||
| replacements: dict[int, str] = {} | ||
|
|
||
| for match in matches: | ||
| owner_repo = resolve_owner_repo(match, current_owner, current_repo_name) | ||
| key = (owner_repo, match.issue) | ||
| if key not in issue_cache: | ||
| issue_cache[key] = fetch_issue(owner_repo, match.issue) | ||
|
|
||
| issue = issue_cache[key] | ||
| if issue is None: | ||
| continue | ||
|
|
||
| replacement = f"{match.prefix}{match.keyword} [{match.display}]({issue.url}){match.suffix}\n" | ||
| if issue.linear_id is not None and issue.linear_url is not None: | ||
| next_line = lines[match.line_index + 1] if match.line_index + 1 < len(lines) else "" | ||
| linear_line = f"{match.prefix}{match.keyword} [{issue.linear_id}]({issue.linear_url})\n" | ||
| if next_line != linear_line: | ||
| replacement += linear_line | ||
| replacements[match.line_index] = replacement | ||
|
|
||
| if not replacements: | ||
| return | ||
|
|
||
| new_lines = [replacements.get(index, line) for index, line in enumerate(lines)] | ||
| try: | ||
| path.write_text("".join(new_lines), encoding="utf-8") | ||
| except OSError as exc: | ||
| warn(f"could not write commit message: {exc}") | ||
|
|
||
|
|
||
| def main(argv: list[str]) -> int: | ||
| if len(argv) != 2: | ||
| warn("expected exactly one commit message file path") | ||
| return 0 | ||
|
|
||
| process_message(Path(argv[1])) | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| raise SystemExit(main(sys.argv)) | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Skill references nonexistent script path from repo root
Medium Severity
The SKILL.md instructs agents to run
scripts/fetch-commit-guidelines.sh, but that path doesn't exist in the repo-rootscripts/directory (which only containsbump-version.sh,commit-msg-expand-issues.py,generate-readme.sh, andupdate-readme.sh). The actual script lives at.agents/skills/commit/scripts/fetch-commit-guidelines.sh. An agent executing this from the repository root will get a "file not found" error, causing the skill's first step (fetching commit guidelines) to fail entirely.Reviewed by Cursor Bugbot for commit 80c71ac. Configure here.