d2cc543ed2c0fc032bc0cf8527ecff098b075aef braney Sun Jul 26 17:24:31 2026 -0700 record the ticket that introduced each hg.conf variable refs #37925 Each setting now carries the Redmine ticket cited by the commit that added its read, or for a flag the commit that turned its default on. --sunset prints it per gate, --html links it, and harvestHgConf.py --tickets groups the settings by whether git can attribute them at all. Nothing looser is used: a commit that merely edits a line is not a commit about the setting on it. A second history walk dates the names that reach cfgOption through a macro, so hgConfAges.json is rebuilt and a refresh now costs about four minutes. No CGI behaviour changes. diff --git src/hg/utils/hgConfCatalog/hgConfCatalog.py src/hg/utils/hgConfCatalog/hgConfCatalog.py index 71556cb654c..127d00e9b59 100755 --- src/hg/utils/hgConfCatalog/hgConfCatalog.py +++ src/hg/utils/hgConfCatalog/hgConfCatalog.py @@ -64,30 +64,32 @@ hgConfCatalog.py --check # counts and internal consistency hgConfCatalog.py --reconcile # diff the catalog against the tree hgConfCatalog.py --sunset # what should be deleted, and when """ import argparse import html import json import os import sys # Sunset policy, in releases. See the module docstring. KEEP_AFTER_FLIP = 4 QA_GRACE = 6 +REDMINE = "https://redmine.gi.ucsc.edu/issues/%d" + # --------------------------------------------------------------------------- # helpers # --------------------------------------------------------------------------- def h(name, kind, src, default=None, note=None, public=False, verified=False, role=None, sunset=None, env=None, deprecated=False, family=None, required=False, ticket=None, debatable=None): """One catalog entry. name the hg.conf setting name kind what sort of setting: path, table, profile, credential, url, email, limit, flag, branding, debug, internal, dead src file:line where the tree reads it default compiled-in default if the read supplies one @@ -1277,87 +1279,103 @@ # --------------------------------------------------------------------------- # sunset report # --------------------------------------------------------------------------- def gate_lifecycle(cat, ages): """Join each gate with the version history. Returns a record per gate carrying what the tree knows (added, flipped, current default) and what the catalog decided (sunset). Everything the report says follows from this join, so a gate cannot be described as healthy just because nobody updated its entry. """ first = ages.get("first", {}) first_true = ages.get("firstTrue", {}) cur = ages.get("current") + hh = load_harvester() out = [] for v in gates(cat): name = v["name"] added = (first.get(name) or {}).get("version") flipped = (first_true.get(name) or {}).get("version") + tickets, ticket_from = ([], None) + if hh and hasattr(hh, "ticket_for"): + tickets, ticket_from = hh.ticket_for(name, ages) shipped = v.get("default") == "TRUE" # A flip date with a FALSE default now means the flip was reverted, so # the flag is back to gating and the flip date must not drive a # deadline. reverted = bool(flipped) and not shipped sunset = v.get("sunset") if sunset is None and shipped and flipped: sunset = flipped + KEEP_AFTER_FLIP out.append({ "name": name, "src": v["src"], "default": v.get("default"), "note": v.get("note"), "added": added, "flipped": flipped, "shipped": shipped, "reverted": reverted, "sunset": sunset, "current": cur, "age": (cur - added) if (cur and added) else None, + "tickets": tickets, "ticketFrom": ticket_from, }) return out def sunset_report(cat, ages, sites=None, out=sys.stdout): """What should be deleted, what has no deadline, what is stuck in QA.""" cur = ages.get("current") life = gate_lifecycle(cat, ages) print("current tree version: v%s" % cur, file=out) if ages.get("stale"): print("\nWARNING: the age cache was built at v%s and the tree is now " "at v%s.\nDeadlines below are still correct, but any flag added " "since v%s has no date\nand will show as 'age unknown' rather " "than being reported. Rebuild with\nharvestHgConf.py --age " "--refresh.\n" % (ages.get("cachedAt"), cur, ages.get("cachedAt")), file=out) undated = [g for g in life if g["added"] is None] if undated: print("\n%d gate(s) could not be dated from history, so no deadline " "applies to them:\n %s\n" % (len(undated), ", ".join(sorted(g["name"] for g in undated))), file=out) print("policy: keep a flag %d releases after its default flips TRUE; " "a gate\nstill defaulting FALSE after %d releases is stalled.\n" % (KEEP_AFTER_FLIP, QA_GRACE), file=out) + noticket = [g["name"] for g in life if not g["tickets"]] + if noticket: + print("The ticket column is the ticket cited by the commit that added " + "the flag, or\nby the commit that turned it on, marked (flip). " + "%d of %d gates have neither\nand are blank: %s\n" + % (len(noticket), len(life), ", ".join(sorted(noticket))), + file=out) def line(g): bits = [] if g["added"]: bits.append("added v%d" % g["added"]) if g["flipped"] and not g["reverted"]: bits.append("flipped v%d" % g["flipped"]) if g["reverted"]: bits.append("flip v%d reverted" % g["flipped"]) if g["sunset"]: bits.append("sunset v%d" % g["sunset"]) n = len((sites or {}).get(g["name"], [])) or None tail = "%d call site%s" % (n, "" if n == 1 else "s") if n else "" - return " %-26s %-46s %s" % (g["name"], ", ".join(bits), tail) + tik = ", ".join("#%d" % t for t in g["tickets"]) + if tik and g["ticketFrom"] == "flip": + tik += " (flip)" + return " %-26s %-46s %-16s %s" % (g["name"], ", ".join(bits), + tik, tail) overdue = sorted([g for g in life if g["sunset"] and cur and g["sunset"] <= cur], key=lambda g: g["sunset"]) print("OVERDUE (delete the flag and every branch that reads it): %d" % len(overdue), file=out) for g in overdue: print(line(g), file=out) due = sorted([g for g in life if g["sunset"] and cur and g["sunset"] > cur], key=lambda g: g["sunset"]) print("\nSCHEDULED (shipped, deadline not yet reached): %d" % len(due), file=out) for g in due: print(line(g), file=out) @@ -1614,186 +1632,240 @@ div.arguable { color: #6b4a00; background: #fdf6e3; border-left: 3px solid #d9a441; padding: 3px 7px; margin-top: 4px; font-size: 0.93em; } span.arguable { background: #fdf0c0; color: #6b5300; } div.box { background: #f6f8fb; border-left: 4px solid #4b6c9e; padding: 0.7em 1em; margin: 1em 0; } div.policy { background: #fff8ec; border-left: 4px solid #d9a441; padding: 0.7em 1em; margin: 1em 0; } ul.toc { columns: 3; list-style: none; padding-left: 0; font-size: 0.92em; } """ def esc(s): return html.escape(str(s), quote=False) -def var_rows(vs, life_by_name=None): +def ticket_map(cat, ages): + """name -> (tickets, kind) for every setting git can attribute. + + kind is "introduced" when the commit that added the read cited the ticket + and "flip" when only the commit that turned a flag on did. A setting + missing from this map has no ticket in either commit, which for anything + added before about v270 means the tree predates Redmine. + """ + hh = load_harvester() + if not (ages and hh and hasattr(hh, "ticket_for")): + return {} + out = {} + for v in all_vars(cat): + tickets, kind = hh.ticket_for(v["name"], ages) + if tickets: + out[v["name"]] = (tickets, kind) + return out + + +def ticket_links(rec): + tickets, kind = rec + links = " ".join('<a href="%s">#%d</a>' % (REDMINE % t, t) for t in tickets) + if kind == "flip": + return "turned on by %s" % links + return "introduced by %s" % links + + +def var_rows(vs, life_by_name=None, tickets=None): rows = [] for v in sorted(vs, key=lambda x: x["name"].lower()): tags = ['<span class="kind">%s</span>' % esc(v["kind"])] role = v.get("role") if role: tags.append('<span class="kind %s">%s</span>' % (role, role)) if v.get("required"): tags.append('<span class="kind req">required</span>') if v.get("deprecated"): tags.append('<span class="kind dep">retired</span>') life = (life_by_name or {}).get(v["name"]) if life: cur = life.get("current") if life.get("sunset") and cur and life["sunset"] <= cur: tags.append('<span class="kind overdue">overdue v%d</span>' % life["sunset"]) elif life.get("sunset"): tags.append('<span class="kind">sunset v%d</span>' % life["sunset"]) if (not life.get("shipped") and life.get("age") is not None and life["age"] > QA_GRACE): tags.append('<span class="kind stalled">stalled %d</span>' % life["age"]) extra = "" if life and life.get("added"): extra = "added v%d" % life["added"] if life.get("flipped"): extra += ", flipped v%d" % life["flipped"] + tik = (tickets or {}).get(v["name"]) + if tik: + extra += (", " if extra else "") + ticket_links(tik) note = "" if v.get("note"): note = '<div class="note">%s</div>' % esc(v["note"]) if v.get("debatable"): tags.append('<span class="kind arguable">gate or knob?</span>') note += ('<div class="arguable"><b>Arguable:</b> %s</div>' % esc(v["debatable"])) env = "" if v.get("env"): env = '<div class="note">environment: <code>%s</code></div>' \ % esc(v["env"]) default = esc(v.get("default") or "") rows.append( "<tr><td class='name'><code>%s</code>%s%s</td>" "<td>%s</td><td><code>%s</code></td>" "<td class='src'><code>%s</code>%s</td></tr>" % (esc(v["name"]), note, env, " ".join(tags), default, esc(v["src"]), ("<div class='note'>%s</div>" % extra) if extra else "")) return "\n".join(rows) -def table_of(vs, life_by_name=None): +def table_of(vs, life_by_name=None, tickets=None): return ("<table><tr><th>setting</th><th>kind</th><th>default</th>" "<th>read at</th></tr>\n%s\n</table>" - % var_rows(vs, life_by_name)) + % var_rows(vs, life_by_name, tickets)) def render_html(cat, ages=None, sites=None): life_by_name = {} sunset_html = "" + tickets = ticket_map(cat, ages) if ages: life = gate_lifecycle(cat, ages) life_by_name = {g["name"]: g for g in life} cur = ages.get("current") overdue = [g for g in life if g["sunset"] and cur and g["sunset"] <= cur] stalled = [g for g in life if not g["shipped"] and g["age"] is not None and g["age"] > QA_GRACE] sunset_html = ( '<div class="policy"><b>Sunset status at v%s.</b> ' '%d shipped gates are past their removal deadline and %d have been ' 'sitting at a FALSE default for more than %d releases. ' 'Policy: keep a flag %d releases after its default flips TRUE, ' 'then delete it and every branch that reads it. ' '<code>hgConfCatalog.py --sunset</code> prints the working list.' '</div>' % (cur, len(overdue), len(stalled), QA_GRACE, KEEP_AFTER_FLIP)) c = counts(cat) parts = ["<h1>Genome Browser hg.conf variables</h1>", "<p>%d settings the CGIs read from <code>hg.conf</code>, " "generated from <code>hg/utils/hgConfCatalog/</code>. " "%d are release gates and %d are permanent deployment knobs." "</p>" % (c["distinctNames"], c["gates"], c["knobs"]), '<div class="box">%s</div>' % esc(cat["boundary"]), sunset_html] + if tickets: + intro = len([1 for _, k in tickets.values() if k == "introduced"]) + parts.append( + '<div class="policy"><b>Where a setting came from.</b> Each entry ' + 'below carries the Redmine ticket cited by the commit that added ' + 'the read, or for a flag the commit that turned its default on, ' + 'marked there as "turned on by". %d of %d settings are attributed ' + 'that way. The rest are blank on purpose: no commit in the chain ' + 'names a ticket, and for anything added before about v270 the tree ' + 'predates our use of Redmine, so there is nothing to find. Nothing ' + 'here is inferred from a later commit that merely edited the line, ' + 'which would have credited half the file to whatever refactor ' + 'touched it last. <code>harvestHgConf.py --tickets</code> lists ' + 'the unattributed settings, separating the ones old enough to be ' + 'hopeless from the ones somebody could still fill in.</div>' + % (intro, c["distinctNames"])) + parts.append("<h2>Contents</h2><ul class='toc'>") for sec in cat["sections"]: parts.append("<li><a href='#%s'>%s</a></li>" % (esc(sec["title"].replace(" ", "-")), esc(sec["title"]))) parts.append("</ul>") parts.append("<h2>How a setting is read</h2>") parts.append("<table><tr><th>accessor</th><th>behaviour</th></tr>") for fn, what in sorted(ACCESSORS.items()): parts.append("<tr><td><code>%s</code></td><td>%s</td></tr>" % (esc(fn), esc(what))) parts.append("</table>") ps = cat["profileSuffixes"] parts.append("<h2>Database profile suffixes</h2>") parts.append("<p class='what'>%s</p>" % esc(ps["what"])) parts.append("<p>Suffixes: %s</p>" % ", ".join("<code>%s</code>" % esc(s) for s in ps["suffixes"])) parts.append("<p>Profiles in use: %s</p>" % ", ".join("<code>%s.</code>" % esc(s) for s in ps["knownProfiles"])) for sec in cat["sections"]: parts.append("<h2 id='%s'>%s</h2>" % (esc(sec["title"].replace(" ", "-")), esc(sec["title"]))) parts.append("<p class='what'>%s</p>" % esc(sec["what"])) - parts.append(table_of(sec["vars"], life_by_name)) + parts.append(table_of(sec["vars"], life_by_name, tickets)) return ("<!DOCTYPE html>\n<html><head><meta charset='utf-8'>" "<title>hg.conf variables</title><style>%s</style></head>" "<body>\n%s\n</body></html>\n" % (CSS, "\n".join(parts))) # --------------------------------------------------------------------------- # main # --------------------------------------------------------------------------- def main(): ap = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--json") ap.add_argument("--html") ap.add_argument("--check", action="store_true") ap.add_argument("--reconcile", action="store_true") ap.add_argument("--sunset", action="store_true") args = ap.parse_args() cat = build() ages = None sites = None - if args.sunset or args.html: + if args.sunset or args.html or args.json: hh = load_harvester() if hh is None: print("harvestHgConf.py not importable", file=sys.stderr) return 1 - ages = hh.harvest_ages() found, _ = hh.harvest() + ages = hh.harvest_ages(names=sorted(hh.by_name(found))) sites = {n: d["sites"] for n, d in hh.by_name(found).items()} rc = 0 if args.check: rc |= 1 if check(cat) else 0 if args.reconcile: rc |= 1 if reconcile(cat) else 0 if args.sunset: sunset_report(cat, ages, sites) if args.json: + # Attribution rides along with each setting rather than in a table of + # its own, so a consumer reading one entry sees where it came from. + tmap = ticket_map(cat, ages) + for v in all_vars(cat): + if v["name"] in tmap: + v["tickets"], v["ticketFrom"] = tmap[v["name"]] with open(args.json, "w") as f: json.dump(cat, f, indent=1) print("wrote %s" % args.json) if args.html: with open(args.html, "w") as f: f.write(render_html(cat, ages, sites)) print("wrote %s" % args.html) if not any([args.check, args.reconcile, args.sunset, args.json, args.html]): for k, v in sorted(counts(cat).items()): print("%-16s %s" % (k, v)) return rc if __name__ == "__main__":