Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .agents/skills/commit/SKILL.md
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.
5 changes: 5 additions & 0 deletions .agents/skills/commit/scripts/fetch-commit-guidelines.sh
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"
8 changes: 8 additions & 0 deletions .pre-commit-config.yaml
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]
244 changes: 244 additions & 0 deletions scripts/commit-msg-expand-issues.py
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+"
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))
65 changes: 65 additions & 0 deletions sentry-types/src/macros.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,3 +246,68 @@ mod hex_tests {
);
}
}

/// A macro which can wrap any number of enum definitions to make them "indexed."
///
/// Specifically, the macro adds an implementation to each enum, which contains the following:
/// - `const VARIANT_COUNT`: the total number of variants in the enum.
/// - `const fn as_index(&self)`: the unique zero-based index of the enum variant.
/// Both of these items have the same visibility as the enum itself.
///
/// This is super useful, for example, if you want to store something for each variant. Rather
/// than using a `HashMap`, it is possible to allocate a fixed-length array of length
/// `VARIANT_COUNT`, indexed by `as_index`.
macro_rules! indexed_enum {
() => {};

{
$(#[$meta:meta])*
$vis:vis enum $name:ident {
$(
$(#[$variant_meta:meta])*
$variant:ident
),* $(,)?
}
$($rest:tt)*
} => {
$(#[$meta])*
$vis enum $name {
$(
$(#[$variant_meta])*
$variant,
)*
}

impl $name {
/// The number of variants in this enum.
$vis const VARIANT_COUNT: usize = indexed_enum!(@count $($variant),*);

/// Returns this variant's unique zero-based index.
///
/// The index satisfies `0 <= self.as_index() < Self::VARIANT_COUNT`.
$vis const fn as_index(&self) -> usize {
indexed_enum!(@match_stmt *self; [] 0usize; $($variant),*)
}
}

indexed_enum! {
$($rest)*
}
};

(@match_stmt $value:expr; [$($arms:tt)*] $idx:expr;) => {
match $value {
$($arms)*
}
};

(@match_stmt $value:expr; [$($arms:tt)*] $idx:expr; $variant:ident $(, $rest:ident)*) => {
indexed_enum!(@match_stmt $value; [$($arms)* Self::$variant => $idx,] $idx + 1usize; $($rest),*)
};

(@count) => { 0usize };

(@count $variant:ident $(, $rest:ident)*) => {
1usize + indexed_enum!(@count $($rest),*)
};
}
Loading