blob: 229a875d9076e0ebbdd116bffec24cc42642ec99 [file]
#!/usr/bin/env python3
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
"""Record the commits a root bead's work merged as.
Run at merge, as part of the pin step -- and only then. A commit's sha is not
final until the work has landed on a published branch, because squash and rebase
rewrite whatever the branch held before that. TinkerPop merges forward with real
merge commits, so a sha published on 3.7-dev is byte-identical on 3.8-dev and
master, and the sha recorded here stays correct for good.
Creates one `record` bead per landing, under the root:
title / external-ref apache/tinkerpop@<tip> GitHub's owner/repo@sha form,
the sibling of the PR record
apache/tinkerpop#3578
notes every sha, full length
The shas go in `notes` because that is the one free-text field bd can query.
`bd search` indexes title and id only, and `bd query` has no field for comments
or design, so a sha recorded anywhere else is unreachable except by grepping an
export. `bd query 'notes=...'` is a substring match, which is why the full
40-character sha is stored: a short sha pasted from `git log --oneline` is a
prefix of it, so both forms of the query find the bead. Quote the sha inside the
query: most shas begin with a digit, and an unquoted one is lexed as a number and
fails to parse, the same way an unquoted version label does.
bd query 'notes="e1ca7ea3e4"' --all --json # -> the record, and its parent root
That is the reverse direction, from a line of `git log` back to the reasoning.
The forward direction is `bd show <record>`.
The operator says when work has merged, in the way they would say a JIRA issue is
ready to resolve. `--suggest` answers with the commits that appear to belong to
the root, for them to confirm or correct:
bin/beads-commits.py --root tp-abc --branch 3.7-dev --suggest
The root's own record beads are what identify them. A JIRA record gives the
ticket id that TinkerPop commit subjects carry, and a PR record gives a number
GitHub can resolve to the commit a squash merge produced. A root with neither
falls back to the commits on the branch since it was created, which is a list to
choose from rather than an answer. Nothing is written in this mode.
Once the operator confirms, the same command records them:
bin/beads-commits.py --root tp-abc --branch 3.7-dev --pr 3610
bin/beads-commits.py --root tp-abc --branch 3.7-dev --commits 374b0c76d0 e1ca7ea3e4
bin/beads-commits.py --root tp-abc --branch 3.7-dev --range 74fe2a64d0..374b0c76d0
What this script guarantees is the part that is easy to get wrong: every sha is
verified reachable from origin/<branch> before anything is written, and a re-run
finds the existing record rather than creating a second one.
"""
import argparse
import json
import re
import subprocess
import sys
from datetime import datetime
def git(*args):
"""Run a git command and return its stripped stdout."""
proc = subprocess.run(["git", *args], capture_output=True, text=True)
if proc.returncode != 0:
sys.exit(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
return proc.stdout.strip()
def bd(*args):
"""Run a bd command with --json and return the parsed result."""
proc = subprocess.run(["bd", *args, "--json"], capture_output=True, text=True)
if proc.returncode != 0:
sys.exit(f"bd {' '.join(args)} failed: {proc.stderr.strip()}")
if not proc.stdout.strip():
return []
return json.loads(proc.stdout)
def as_list(payload):
"""bd sometimes returns a bare object where a list is expected."""
if isinstance(payload, dict):
return payload.get("issues", [payload])
return payload
def repo_slug():
"""owner/repo for the origin remote, so a fork records its own commits."""
url = git("remote", "get-url", "origin")
match = re.search(r"[:/]([^/:]+/[^/]+?)(?:\.git)?$", url)
if not match:
sys.exit(f"cannot read owner/repo out of origin url: {url}")
return match.group(1)
def commits_from_pr(number):
"""The commit GitHub recorded for the merge -- one, for a squash merge."""
proc = subprocess.run(
["gh", "pr", "view", str(number), "--json", "mergeCommit,title,url"],
capture_output=True, text=True)
if proc.returncode != 0:
sys.exit(f"gh pr view {number} failed: {proc.stderr.strip()}")
payload = json.loads(proc.stdout)
merge_commit = payload.get("mergeCommit") or {}
if not merge_commit.get("oid"):
sys.exit(f"PR {number} has no merge commit -- is it merged?")
return [merge_commit["oid"]]
def bd_show(bead_id):
"""One bead, or None when the id does not resolve."""
found = as_list(bd("show", bead_id))
return found[0] if found else None
def pr_merge_commit(number):
"""The merge commit for a PR, or None when it is unmerged or unreachable."""
proc = subprocess.run(
["gh", "pr", "view", str(number), "--json", "mergeCommit"],
capture_output=True, text=True)
if proc.returncode != 0:
return None
return ((json.loads(proc.stdout).get("mergeCommit") or {}).get("oid")) or None
def suggest(root, branch):
"""Print the commits that look like this root's, for the operator to confirm.
The root's record beads are the discovery mechanism, which is what PRIME.md
section 6 means by calling records the link between beads and code. A JIRA
record names a ticket that TinkerPop commit subjects carry, and a PR record
names a pull request GitHub resolves to a commit. Neither involves guessing.
"""
bead = bd_show(root)
if not bead:
sys.exit(f"no bead {root}")
ref = f"origin/{branch}"
git("rev-parse", "--verify", ref)
print(f"{root} ยท {bead.get('title', '')}\n")
# why[sha] collects every record that points at the same commit
why = {}
for child in as_list(bd("children", root)):
if child.get("issue_type") != "record":
continue
external = child.get("external_ref") or child.get("title") or ""
if re.fullmatch(r"[A-Z]+-\d+", external):
found = git("log", "--format=%H", f"--grep={external}", ref).splitlines()
elif re.search(r"#(\d+)$", external):
oid = pr_merge_commit(re.search(r"#(\d+)$", external).group(1))
found = [oid] if oid else []
else:
continue
for sha in found:
why.setdefault(sha, []).append(f"{child['id']} ({external})")
# The window is shown even when records identified something. A ticket id only
# finds the commits that quote it, and a follow-up fix or a docs pass usually
# does not, so returning here would hide exactly the commits most easily lost.
# Filter on committer date, in Python, because `git log --since` compares the
# AUTHOR date. A merged pull request carries the contributor's authoring date,
# often weeks before it landed, so --since would drop the commits this window
# exists to catch.
stamp = bead.get("started_at") or bead.get("created_at") or ""
since = stamp[:10]
window = []
if stamp:
# A day of slack. PRIME.md has the root created before the code, but a root
# is sometimes opened once work is already under way, and the operator
# confirms this list. Showing a commit that does not belong costs a glance;
# hiding one that does costs the link entirely.
cutoff = datetime.fromisoformat(stamp.replace("Z", "+00:00")).timestamp() - 86400
for entry in git("log", "--format=%H %ct", "-200", ref).splitlines():
sha, _, committed = entry.partition(" ")
if int(committed) >= cutoff:
window.append(sha)
if len(window) >= 25:
break
mine = (git("config", "user.email") or "").lower()
def line(sha):
who = git("log", "-1", "--format=%ae %ce", sha).lower()
return f" {sha[:10]} {subject(sha)}{' [you]' if mine and mine in who else ''}"
if why:
print(f"identified by this root's records, on {ref}:")
for sha, reasons in why.items():
print(line(sha))
print(f" {', '.join(reasons)}")
else:
print("no JIRA or PR record on this root, so nothing identifies its commits directly.")
# When records already answered, the window is a prompt rather than a list: it
# exists so a follow-up commit that quotes no ticket is not silently lost. A
# long-lived root accumulates unrelated work, so showing all of it would bury
# the answer. When records answered nothing, the window is all there is.
rest = [sha for sha in window if sha not in why]
shown = rest[:5] if why else rest
if shown:
print(f"\nalso on {ref} since {since}, identified by nothing:")
for sha in shown:
print(line(sha))
if len(rest) > len(shown):
print(f" ... and {len(rest) - len(shown)} older, "
f"'git log {ref} --since={since}' for the rest")
elif not why:
print(f"and no commits on {ref} since {since or 'the root was created'}.")
return
confirmed = " ".join(sha[:10] for sha in why) if why else "<sha>..."
print(f"\nconfirm the list with the operator, adding any of the above, then:\n"
f" bin/beads-commits.py --root {root} --branch {branch} --commits {confirmed}")
def resolve(args):
"""The commit list, oldest first, in whichever way the caller named it."""
if args.pr:
return commits_from_pr(args.pr)
if args.range:
listed = git("log", "--format=%H", "--reverse", args.range).splitlines()
if not listed:
sys.exit(f"{args.range} names no commits")
return listed
return args.commits
def verify(shas, branch):
"""Expand to full length and refuse anything not on the published branch."""
ref = f"origin/{branch}"
git("rev-parse", "--verify", ref) # exits if the branch was never fetched
verified, unreachable = [], []
for sha in shas:
full = git("rev-parse", f"{sha}^{{commit}}")
reachable = subprocess.run(
["git", "merge-base", "--is-ancestor", full, ref],
capture_output=True, text=True).returncode == 0
(verified if reachable else unreachable).append(full)
if unreachable:
print(f"not reachable from {ref}:", file=sys.stderr)
for full in unreachable:
print(f" {full[:10]} {subject(full)}", file=sys.stderr)
sys.exit(
f"\nRefusing to record these. A commit that has not landed on {ref} can still\n"
"be rewritten by a squash or a rebase, and the sha would then point at nothing.\n"
"Run this once the work has merged. If it has, fetch first (--fetch).")
return verified
def subject(sha):
return git("log", "-1", "--format=%s", sha)
def chronological(shas):
"""Oldest first, so the tip is genuinely the last commit to land.
The tip names the record, so the caller's argument order must not decide it.
`--suggest` emits in record-discovery order and a human types them in any
order at all, both of which would otherwise title the record with the wrong
commit and date the landing to the wrong day.
"""
return sorted(shas, key=lambda sha: int(git("log", "-1", "--format=%ct", sha)))
def existing_record(root, tip):
"""The record for this landing, if a previous run already wrote it."""
# The value has to be quoted inside the query. A sha that starts with a digit
# is otherwise lexed as a number and the parse fails -- the same trap
# release.asciidoc documents for version labels, and most shas start with one.
for bead in as_list(bd("query", f'notes="{tip}"', "--all")):
if bead.get("parent") == root:
return bead
return None
def main():
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("--root", required=True, help="root bead the work hangs off")
parser.add_argument("--branch", required=True,
help="published branch the work merged to, e.g. 3.7-dev")
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument("--pr", type=int, help="pull request number; asks gh for the merge commit")
source.add_argument("--commits", nargs="+", metavar="SHA", help="the commits, oldest first")
source.add_argument("--range", metavar="A..B", help="a git revision range")
source.add_argument("--suggest", action="store_true",
help="print the commits that look like this root's; writes nothing")
parser.add_argument("--fetch", action="store_true",
help="refresh origin/<branch> first; otherwise nothing touches the network")
parser.add_argument("--dry-run", action="store_true", help="print the record, write nothing")
args = parser.parse_args()
if args.fetch:
git("fetch", "origin", args.branch)
if args.suggest:
suggest(args.root, args.branch)
return
shas = chronological(verify(resolve(args), args.branch))
tip = shas[-1]
slug = repo_slug()
ref = f"{slug}@{tip[:10]}"
merged_on = git("log", "-1", "--format=%cs", tip)
lines = [f"merged {args.branch} {merged_on}"]
lines += [f" {sha} {subject(sha)}" for sha in shas]
notes = "\n".join(lines)
found = existing_record(args.root, tip)
if found:
print(f"{found['id']} already records this landing under {args.root}; nothing to do")
return
print(f"{ref}\n{notes}\n")
if args.dry_run:
print(f"--dry-run: would create a record under {args.root} and pin it")
return
created = as_list(bd("create", "--type=record", f"--parent={args.root}",
f"--title={ref}", f"--external-ref={ref}", f"--notes={notes}"))
record = created[0]["id"]
# Records are created pinned (PRIME.md section 6), but `bd create` has no
# status flag, so pinning is a second call.
bd("update", record, "-s", "pinned")
print(f"created {record} under {args.root}, pinned, {len(shas)} commit(s)")
if __name__ == "__main__":
main()