From patchwork Thu Oct 22 17:29:54 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Ray Strode X-Patchwork-Id: 28562 Return-Path: X-Original-To: parsemail@patchwork.libcamera.org Delivered-To: parsemail@patchwork.libcamera.org Received: from lancelot.ideasonboard.com (lancelot.ideasonboard.com [92.243.16.209]) by patchwork.libcamera.org (Postfix) with ESMTPS id D53BCC324C for ; Thu, 22 Oct 2026 17:29:57 +0000 (UTC) Received: from lancelot.ideasonboard.com (localhost [IPv6:::1]) by lancelot.ideasonboard.com (Postfix) with ESMTP id 8888A68F8E; Thu, 22 Oct 2026 19:29:57 +0200 (CEST) Authentication-Results: lancelot.ideasonboard.com; dkim=pass (2048-bit key; unprotected) header.d=gmail.com header.i=@gmail.com header.b="YTY1NmxK"; dkim-atps=neutral Received: from mail-yx1-xb12c.google.com (mail-yx1-xb12c.google.com [IPv6:2607:f8b0:4864:20::b12c]) by lancelot.ideasonboard.com (Postfix) with ESMTPS id 3352868E17 for ; Thu, 22 Oct 2026 19:29:56 +0200 (CEST) Received: by mail-yx1-xb12c.google.com with SMTP id 956f58d0204a3-6774639a8a4so152182d50.0 for ; Thu, 22 Oct 2026 10:29:56 -0700 (PDT) ARC-Seal: i=1; a=rsa-sha256; t=1792690195; cv=none; d=google.com; s=arc-20260327; b=mp44BI0ma+XA6bHZaJJlPFeWi8PQTQfRM9hlwo9YMYwMCzgfjUA53Lcz4YfkpYqjVP StdydfICBXJ1/O8be5rCNNzBL2UK65/bYs3dsuMKGBsGQ3QcdKwoyKO7XhASCEAhDv64 p4mq/HtMstdfTugJQuzo9T2EkqnBCFnTJCFGC8iDzc+xuSTpaf19Im+5rF81BJEDJXp5 9bem6MgElp9ZcuqSXbmtB7UaQcfEWWyuS7tQApuWKXANSblNlddquUVrpP0094OGjN3K gsvMpJbY6tJ2udmNsH6iwu0qu1RNB5kw/BRNZOA0bjXrm+CPxd5okcM7JkwnL9w+QG3K l12Q== ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20260327; h=cc:to:subject:message-id:date:references:in-reply-to:from :mime-version:dkim-signature; bh=ox6kU/1bTjvKaN1oKbJx9MBQmG4ntCmbLR8JhUia5Io=; fh=KRWpOUVUb6PgSdBVDB+V+zdegqhVAongcWJguCVTXWM=; b=Ugmitz3mhvC3xnwYBLRuuRST5Wu3Vw84xlB+T3OhN8LUo9QV9EzEyWfLJEsWDeE7aH pPwUA2FROaispLlY+F3a6u0k3pcOJ8oI8vy1JZ9DsuXRyQWvXY1jwP7pmhpYnDXto75I U0zpxh457vEJXAcZOvEM4uiW1OQeLptkn0i/R3Rds70t6ArkQtYKFWyN6QS60uoOE9Bv UoPO73tz9BT6B6D7/yWxNoLs2stxbirOXbay7HxRIwf/PU0+onP/N67C+vaJ3AWWMaPC dUJrvbXOO8JQGDrshzwWYSQpO5YmTI/1fXy98ugHN+cFJOC6NlQnElYl6fwjwE+5eeGh Wxog==; darn=lists.libcamera.org ARC-Authentication-Results: i=1; mx.google.com; arc=none DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20251104; t=1792690195; x=1793294995; darn=lists.libcamera.org; h=content-type:cc:to:subject:message-id:date:references:in-reply-to :from:mime-version:from:to:cc:subject:date:message-id:reply-to :content-type; bh=ox6kU/1bTjvKaN1oKbJx9MBQmG4ntCmbLR8JhUia5Io=; b=YTY1NmxKBlwdzKbVxnjhslf+J+YxCp0WuzOEYuaCp7K30b0gdbQEOZogAgAVjjMcJr Lzto1dmJeLPFazqNT/6MIjAyH758NH7p/qA4g5djBjqlM3IiGcuWcJlqLg6nQCfBuOqN gArp7PCwZCO8UiH2oiDJx8ttuinXYeE4hP4Rd46e/GK54IiJxPtYBNCfhJs/RNyazmMG XkVo9g4Ka3Q9RvfU/VfK4ZqgEoCyBBs3ihK9HseASHA7fuVcezqxZC/kLRlyFy/ifT4X WfhjlvDRJWZSPoGUOtA75yNbqP/DYQdVEoHh5/+chKWAa8Ad5IWaRZFYMhgiCsuMwN1T Pdzw== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20260707; t=1792690195; x=1793294995; h=content-type:cc:to:subject:message-id:date:references:in-reply-to :from:mime-version:x-gm-gg:x-gm-message-state:from:to:cc:subject :date:message-id:reply-to:content-type; bh=ox6kU/1bTjvKaN1oKbJx9MBQmG4ntCmbLR8JhUia5Io=; b=WcHh5ZMC3J7l8HlJStVJonPlIETUmhu7k6ajdsI5yfpxrMGJKTi501NQf98yJ6p7Ww Va5VrlPWSHhrd88Rk6XkqbkLfrE+MjfXChfexA3VH2X+XLIOz5BJBCeXieDFuJv30dEu dDI/J5OWk1Lgpf+Hh7yu3+/Hc3psKHCJ6p80Ud3CIfTNFuSrU5rvDe65IDq/JEckQhyN O6m5+cKfGPRF/MDOmiH48cmVNjs4Fp2lozdfJkZ1CBuRpNjRz3mwqTO4u87PUNZioIc4 km0tiLTNNNyIPjDchNeuGa1+Lri48As4+h1rHP9YQp4hu/4Zj1vC4pAY1PRxoQqFLyk/ f/mg== X-Gm-Message-State: AFq9FYJ66MT2p7wY+gKOWuIrVfGid13qH3oIyo7np64X4IZtWInGaBIS 3Oik4GzzBP4TpGq41tK4rsqaJa8IbA/OCWFiruMmAafVHKLZt5A/6KOBM9uttfwBb4vSE5YBJ47 xIRoGu69kw3Xnl8QS1OcYsny60uHoDwA0vQ== X-Gm-Gg: AYBFou1uxMtKm42Fp5V8u1K6Kd2xCuxwQw/7bqlAMYNtojJ++AFg3Opjo+H5ENOShrX 4R+XKdBJiRcTQ7yFPvf81SPsguH7+T4tmU60ta3fq825mHzrGW24ZqWUZg/QOQgX020bQlWtFsX zFFn/BhzQB7CYY3BKZCFNADVJZZB/qZ6hXC+n1mmMoqa/DjQJt6qxxoeGszY/2CV6NOgpZwv6hU a520pP4Coxl78dls9S3oPE9yoUwsnR0R+G5yKNxZxVbNrrL7o6Qoy362F/qlKhaBcvbFHibsexe oqq0not2Ul5QpCq2JZv1fU9VXp80ZS4si35lvk7mukMd1SkWS3C53O/g1LIkJHeRV0LQdUkBWrS q6Dz5bw+3w4vCixuS32iA871xRO+SUIq8yJPwk9kKAt2pDUKO1PjH5LXAu73jJ6h8xcS7jw== X-Received: by 2002:a05:690e:1c0a:b0:679:b8fe:a6ef with SMTP id 956f58d0204a3-67a0e03c684mr2735356d50.14.1792690194918; Thu, 22 Oct 2026 10:29:54 -0700 (PDT) Received: from 77377267392 named unknown by gmailapi.google.com with HTTPREST; Thu, 22 Oct 2026 17:29:54 +0000 Received: from 77377267392 named unknown by gmailapi.google.com with HTTPREST; Thu, 22 Oct 2026 17:29:54 +0000 MIME-Version: 1.0 From: Ray Strode In-Reply-To: References: Date: Thu, 22 Oct 2026 17:29:54 +0000 X-Gm-Features: AclHuK9zqoavYT3pT74YLR9d6kN87oXUzE9Za0wWjkeAGk0AnG2hG5zylaUWvEQ Message-ID: Subject: [PATCH 1/2] utils: Find maintainers from Git history To: libcamera-devel@lists.libcamera.org Cc: =?utf-8?q?Barnab=C3=A1s_P=C5=91cze?= , Kieran Bingham , Jacopo Mondi , Laurent Pinchart , Milan Zamazal , Umang Jain X-BeenThere: libcamera-devel@lists.libcamera.org X-Mailman-Version: 2.1.29 Precedence: list List-Id: List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Errors-To: libcamera-devel-bounces@lists.libcamera.org Sender: "libcamera-devel" From: Ray Strode This commit adds get_maintainer.py to find maintainers and reviewers from local Git history. Contributors can supply patches or repository paths instead of searching commit logs manually for people familiar with an area. The utility ranks review and acknowledgment credits ahead of authorship, normalizes identities through mailmap, and limits contacts per path so busy areas do not crowd out quiet ones. Each result includes roles and commit counts to explain the match. History ends at the configured upstream by default to exclude local unsubmitted work. Kernel-style options control the history window, selection thresholds, and annotations for use with git send-email. Signed-off-by: Ray Strode --- utils/get_maintainer.py | 316 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100755 utils/get_maintainer.py + # cannot inflate a person's counts. + people = contacts(all_commits.values(), names) + for person in sorted(people.values(), key=Contact.rank): + if person.email not in selected or (args.l and person.email == MAILING_LIST): + continue + if args.n: + address = str(Address(person.name, addr_spec=person.email)) + else: + address = person.email + if args.roles or args.rolestats: + address += f' ({person.description(len(all_commits), args.rolestats)})' + print(address) + if args.l: + address = MAILING_LIST + if args.roles or args.rolestats: + address += ' (open list:libcamera-devel)' + print(address) + return 0 + except (OSError, ValueError) as error: + print(f'{Path(argv[0]).name}: {error}', file=sys.stderr) + return 1 + + +if __name__ == '__main__': + sys.exit(main(sys.argv)) base-commit: 7278a59eba64358bbf3d51f557506dd5b0bce5c4 diff --git a/utils/get_maintainer.py b/utils/get_maintainer.py new file mode 100755 index 00000000..3b6af514 --- /dev/null +++ b/utils/get_maintainer.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright (C) 2026, Red Hat Inc. +# +# Author: Ray Strode +# +# Find libcamera maintainers and reviewers from local Git history. + +import argparse +import subprocess +import sys +from dataclasses import dataclass, field +from datetime import datetime, timezone +from email.headerregistry import Address +from email.utils import parseaddr +from pathlib import Path, PurePosixPath + + +MAILING_LIST = 'libcamera-devel@lists.libcamera.org' + + +def git(directory, *args, input=None): + """Run a read-only Git command, keeping diagnostics off standard output.""" + result = subprocess.run(['git', '--no-pager', '--literal-pathspecs', + '-C', str(directory), *args], + input=input, stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + if result.returncode: + raise ValueError(result.stderr.decode(errors='replace').strip()) + return result.stdout.decode('utf-8', errors='surrogateescape') + + +def repository(): + try: + root = git(Path.cwd(), 'rev-parse', '--show-toplevel') + except ValueError: + root = git(Path(__file__).resolve().parent, 'rev-parse', + '--show-toplevel') + return Path(root.strip()).resolve() + + +def default_revision(root): + try: + return git(root, 'rev-parse', '--symbolic-full-name', '@{upstream}').strip() + except ValueError: + return 'HEAD' + + +def file_path(root, name, revision): + # Resolve the parent so that a tracked symlink is treated as a file, even + # when its target is outside the repository. + path = Path(name).absolute() + path = path.parent.resolve() / path.name + try: + relative = path.relative_to(root).as_posix() + except ValueError: + raise ValueError(f'{name}: path is outside the repository') from None + + if not path.exists() and not git(root, 'ls-tree', revision, '--', relative): + raise ValueError(f'{name}: no such file in the worktree or history tip') + return relative or '.' + + +def patch_paths(root, data): + """Let Git parse filenames, including quoted names, renames and deletions.""" + paths = set() + # Forward statistics name a rename's destination; reverse statistics name + # its source. Neither command applies the patch or changes the worktree. + for direction in ((), ('--reverse',)): + output = git(root, 'apply', '--numstat', '-z', *direction, input=data) + for record in output.split('\0'): + if not record: + continue + name = record.split('\t', 2)[2] + path = PurePosixPath(name) + if path.is_absolute() or '..' in path.parts: + raise ValueError(f'{name}: patch path is outside the repository') + paths.add(path.as_posix()) + if not paths: + raise ValueError('patch contains no changed paths') + return paths + + +@dataclass +class Commit: + oid: str + timestamp: int + author: str + reviewers: set + ackers: set + + +def history(root, revision, since, path): + # Ask Git to parse trailers; prose containing "Reviewed-by" is not a credit. + # NUL separators keep the variable-length fields unambiguous. + fmt = ('%H%x00%ct%x00%an <%ae>%x00' + '%(trailers:key=Reviewed-by,valueonly,unfold)%x00' + '%(trailers:key=Acked-by,valueonly,unfold)') + output = git(root, 'log', '--no-merges', '--no-notes', + '--no-show-signature', '-z', f'--format={fmt}', + f'--since-as-filter={since}', revision, '--', path) + fields = output.split('\0')[:-1] + commits = [] + for offset in range(0, len(fields), 5): + oid, timestamp, author, reviews, acks = fields[offset:offset + 5] + commits.append(Commit(oid, int(timestamp), author, + set(reviews.splitlines()), set(acks.splitlines()))) + return commits + + +def identities(root, commits, use_mailmap): + """Canonicalize authors and trailer identities together, once per address.""" + names = set() + for commit in commits: + names.update({commit.author} | commit.reviewers | commit.ackers) + names = sorted(names) + if use_mailmap and names: + data = ('\n'.join(names) + '\n').encode('utf-8', errors='surrogateescape') + canonical = git(root, 'check-mailmap', '--stdin', input=data).splitlines() + else: + canonical = names + + result = {} + for original, mapped in zip(names, canonical): + name, address = parseaddr(mapped) + if '@' in address: + result[original] = (name, address.lower()) + return result + + +@dataclass +class Contact: + name: str + email: str + reviews: set = field(default_factory=set) + acks: set = field(default_factory=set) + authorship: set = field(default_factory=set) + latest: int = 0 + + def rank(self): + # Review experience comes first. Authorship supplies additional contacts + # and breaks ties; the date is the matching commit's date, not a review + # timestamp. Each commit contributes at most once to the review count. + return (-len(self.reviews | self.acks), -len(self.reviews), + -len(self.authorship), -self.latest, self.email) + + def description(self, total, stats): + roles = [] + for role, commits in (('reviewer', self.reviews), ('acker', self.acks), + ('author', self.authorship)): + if not commits: + continue + if stats: + roles.append(f'{role}:{len(commits)}/{total}' + f'={len(commits) * 100 / total:.0f}%') + else: + roles.append(role) + if stats: + date = datetime.fromtimestamp(self.latest, timezone.utc).date() + roles.append(f'last-commit:{date}') + return ', '.join(roles) + + +def contacts(commits, names): + people = {} + for commit in commits: + for members, role in (({commit.author}, 'authorship'), + (commit.reviewers, 'reviews'), + (commit.ackers, 'acks')): + for identity in members: + if identity not in names: + continue + name, email = names[identity] + person = people.setdefault(email, Contact(name, email)) + if commit.timestamp > person.latest: + person.name = name + person.latest = commit.timestamp + getattr(person, role).add(commit.oid) + return people + + +def select_contacts(people, total, args): + selected = [] + for person in sorted(people.values(), key=Contact.rank): + count = len(person.reviews | person.acks | person.authorship) + if count < args.git_min_signatures: + continue + if count * 100 < args.git_min_percent * total: + continue + selected.append(person.email) + if len(selected) == args.git_max_maintainers: + break + return selected + + +def add_boolean_argument(parser, name, default, help_text): + group = parser.add_mutually_exclusive_group() + group.add_argument(f'--{name}', dest=name, action='store_true', help=help_text) + group.add_argument(f'--no{name}', f'--no-{name}', dest=name, + action='store_false', help=f'disable --{name}') + parser.set_defaults(**{name: default}) + + +def parse_arguments(argv): + parser = argparse.ArgumentParser( + description='Find maintainers and reviewers from local Git history.', + usage='%(prog)s [options] patchfile ...\n' + ' %(prog)s [options] -f file-or-directory ...', + epilog='Reads a patch from stdin when no patchfile is given; use - for ' + 'stdin explicitly. History is enabled by default. Contacts are ' + 'selected using Reviewed-by, Acked-by and authorship.') + parser.add_argument('inputs', nargs='*', help='patch files, or paths with -f') + parser.add_argument('-f', '--file', action='store_true', + help='interpret positional arguments as repository paths') + parser.add_argument('--git', action='store_true', + help='use Git history (enabled by default)') + parser.add_argument('--email', action='store_true', + help='print email addresses (enabled by default)') + parser.add_argument('--git-since', default='1-year-ago', metavar='DATE', + help='Git date limiting history (default: 1-year-ago)') + parser.add_argument('--git-revision', metavar='REF', + help='history tip (default: branch upstream, or HEAD)') + parser.add_argument('--git-min-signatures', type=int, default=1, metavar='N', + help='minimum distinct matching commits (default: 1)') + parser.add_argument('--git-min-percent', type=float, default=5, metavar='PCT', + help='minimum share of matching commits (default: 5)') + parser.add_argument('--git-max-maintainers', type=int, default=5, metavar='N', + help='maximum suggestions per input path (default: 5)') + add_boolean_argument(parser, 'mailmap', True, 'normalize identities with Git mailmap') + add_boolean_argument(parser, 'roles', True, 'annotate each address with its roles') + add_boolean_argument(parser, 'rolestats', True, 'include commit counts and last commit date') + add_boolean_argument(parser, 'n', True, 'include names with email addresses') + add_boolean_argument(parser, 'l', True, 'include the libcamera-devel mailing list') + args = parser.parse_intermixed_args(argv[1:]) + if args.file and not args.inputs: + parser.error('-f requires at least one file or directory') + if args.git_min_signatures < 1 or args.git_max_maintainers < 1: + parser.error('commit and contact limits must be positive') + if not 0 <= args.git_min_percent <= 100: + parser.error('--git-min-percent must be between 0 and 100') + if not args.inputs and sys.stdin.isatty(): + parser.error('provide a patchfile, stdin, or -f file-or-directory') + return args + + +def main(argv): + args = parse_arguments(argv) + try: + root = repository() + ref = args.git_revision or default_revision(root) + revision = git(root, 'rev-parse', '--verify', '--end-of-options', + f'{ref}^{{commit}}').strip() + if args.file: + paths = {file_path(root, name, revision) for name in args.inputs} + else: + paths = set() + for name in args.inputs or ['-']: + if name == '-': + data = sys.stdin.buffer.read() + else: + data = Path(name).read_bytes() + paths.update(patch_paths(root, data)) + + if git(root, 'rev-parse', '--is-shallow-repository').strip() == 'true': + print('warning: shallow repository; reviewer history is incomplete', + file=sys.stderr) + + scopes = [] + for path in sorted(paths): + commits = history(root, revision, args.git_since, path) + if not commits: + parent = PurePosixPath(path).parent.as_posix() + # An immediate parent can supply contacts for a new file. Never + # widen a query to the entire repository implicitly. + if parent != '.': + print(f'No recent history for {path}; checking {parent}/', + file=sys.stderr) + commits = history(root, revision, args.git_since, parent) + if not commits: + print(f'No recent history for {path}', file=sys.stderr) + scopes.append(commits) + + all_commits = {commit.oid: commit for scope in scopes for commit in scope} + names = identities(root, all_commits.values(), args.mailmap) + selected = set() + for commits in scopes: + selected.update(select_contacts(contacts(commits, names), + len(commits), args)) + + # Select per path so a busy subsystem cannot crowd out a quiet one. + # Merge evidence by commit ID before printing, so overlapping paths