hgext/pushlog/feed.py (427 lines of code) (raw):

# This software may be used and distributed according to the terms of the # GNU General Public License version 2 or any later version. from datetime import datetime from math import ceil import collections import os import re import sys import time import mercurial.hgweb.webcommands as hgwebcommands import mercurial.hgweb.webutil as webutil from mercurial.hgweb.common import ( ErrorResponse, paritygen, ) from mercurial.node import hex, nullid from mercurial import ( demandimport, error, pycompat, scmutil, templatefilters, templateutil, ) sys.path.append(os.path.dirname(__file__)) with demandimport.deactivated(): from parsedatetime import parsedatetime as pdt xmlescape = templatefilters.xmlescape testedwith = b"4.8 4.9 5.0 5.1 5.2 5.3 5.4 5.5 5.9" minimumhgversion = b"4.8" cal = pdt.Calendar() PUSHES_PER_PAGE = 10 ATOM_MIMETYPE = b"application/atom+xml" class QueryType: """Enumeration of the different Pushlog query types""" DATE, CHANGESET, PUSHID, COUNT = range(4) class PushlogQuery(object): """Represents the internal state of a query to Pushlog""" def __init__(self, repo, urlbase=b"", tipsonly=False): self.repo = repo self.urlbase = urlbase self.tipsonly = tipsonly self.reponame = os.path.basename(repo.root) self.page = 1 self.dates = [] self.entries = [] self.totalentries = 1 # by default, we return the last 10 pushes self.querystart = QueryType.COUNT self.querystart_value = PUSHES_PER_PAGE # don't need a default here, since by default # we'll get everything newer than whatever your start # query is self.queryend = None self.queryend_value = None # Allow query-by-user self.userquery = [] # Allow query-by-individual-changeset self.changesetquery = [] # Allow query-by-branch self.branch = None self.formatversion = 1 # ID of the last known push in the database. self.lastpushid = None def do_query(self): """Figure out what the query parameters are, and query the database using those parameters.""" # Use an unfiltered repo because query parameters may reference hidden # changesets. Hidden changesets are still in the pushlog. We'll # treat them appropriately at the filter layer. self.entries = [] if ( self.querystart == QueryType.COUNT and not self.userquery and not self.changesetquery ): pushes = self.repo.pushlog.pushes( offset=(self.page - 1) * self.querystart_value, limit=self.querystart_value, reverse=True, branch=self.branch, only_replicated=True, ) for push in pushes: if self.tipsonly and push.nodes: nodes = [push.nodes[0]] else: nodes = push.nodes for node in nodes: self.entries.append((push.pushid, push.user, push.when, node)) self.totalentries = self.repo.pushlog.push_count() else: start_when = None start_push_id = None end_when = None end_push_id = None start_node = None end_node = None if self.querystart == QueryType.DATE: start_when = self.querystart_value elif self.querystart == QueryType.PUSHID: start_push_id = int(self.querystart_value) elif self.querystart == QueryType.CHANGESET: start_node = self.querystart_value if self.queryend == QueryType.DATE: end_when = self.queryend_value elif self.queryend == QueryType.PUSHID: end_push_id = int(self.queryend_value) elif self.queryend == QueryType.CHANGESET: end_node = self.queryend_value pushes = self.repo.pushlog.pushes( reverse=True, start_id=start_push_id, start_id_exclusive=True, end_id=end_push_id, end_id_exclusive=False, start_time=start_when, start_time_exclusive=True, end_time=end_when, end_time_exclusive=True, users=self.userquery, start_node=start_node, start_node_exclusive=True, end_node=end_node, end_node_exclusive=False, nodes=self.changesetquery, branch=self.branch, only_replicated=True, ) for push in pushes: if self.tipsonly: nodes = [push.nodes[0]] else: nodes = push.nodes for node in nodes: self.entries.append((push.pushid, push.user, push.when, node)) self.lastpushid = self.repo.pushlog.last_push_id_replicated() def description(self): if ( self.querystart == QueryType.COUNT and not self.userquery and not self.changesetquery ): return b"" bits = [] isotime = lambda x: pycompat.bytestr(datetime.fromtimestamp(x).isoformat(" ")) if self.querystart == QueryType.DATE: bits.append(b"after %s" % isotime(self.querystart_value)) elif self.querystart == QueryType.CHANGESET: bits.append(b"after changeset %s" % self.querystart_value) elif self.querystart == QueryType.PUSHID: bits.append(b"after push ID %s" % self.querystart_value) if self.queryend == QueryType.DATE: bits.append(b"before %s" % isotime(self.queryend_value)) elif self.queryend == QueryType.CHANGESET: bits.append(b"up to and including changeset %s" % self.queryend_value) elif self.queryend == QueryType.PUSHID: bits.append(b"up to and including push ID %s" % self.queryend_value) if self.userquery: bits.append(b"by user %s" % b" or ".join(self.userquery)) if self.changesetquery: bits.append(b"with changeset %s" % b" and ".join(self.changesetquery)) return b"Changes pushed " + b", ".join(bits) def localdate(ts): """Given a timestamp, return a (timestamp, tzoffset) tuple, which is what Mercurial works with. Attempts to get DST correct as well.""" t = time.localtime(ts) offset = time.timezone if t[8] == 1: offset = time.altzone return ts, offset def do_parse_date(datestring): """Given a date string, try to parse it as an ISO 8601 date. If that fails, try parsing it with the parsedatetime module, which can handle relative dates in natural language.""" datestring = datestring.strip() # This is sort of awful. Match YYYY-MM-DD hh:mm:ss, with the time parts all being optional m = re.match( rb"^(?P<year>\d\d\d\d)-(?P<month>\d\d)-(?P<day>\d\d)(?: (?P<hour>\d\d)(?::(?P<minute>\d\d)(?::(?P<second>\d\d))?)?)?$", datestring, ) if m: date = ( int(m.group("year")), int(m.group("month")), int(m.group("day")), m.group("hour") and int(m.group("hour")) or 0, m.group("minute") and int(m.group("minute")) or 0, m.group("second") and int(m.group("second")) or 0, 0, # weekday 0, # yearday -1, ) # isdst else: # fall back to parsedatetime date, x = cal.parse(pycompat.sysstr(datestring)) return time.mktime(date) def pushlog_setup(repo, req): """Given a repository object and a hgweb request object, build a PushlogQuery object and populate it with data from the request. The returned query object will have its query already run, and its entries member can be read.""" try: page = int(req.qsparams.get(b"node", b"1")) except ValueError: page = 1 tipsonly = req.qsparams.get(b"tipsonly", None) == b"1" urlbase = req.advertisedbaseurl query = PushlogQuery(urlbase=urlbase, repo=repo, tipsonly=tipsonly) query.page = page # find start component if b"startdate" in req.qsparams: startdate = do_parse_date(req.qsparams.get(b"startdate", None)) query.querystart = QueryType.DATE query.querystart_value = startdate elif b"fromchange" in req.qsparams: query.querystart = QueryType.CHANGESET query.querystart_value = req.qsparams.get(b"fromchange", None) elif b"startID" in req.qsparams: query.querystart = QueryType.PUSHID query.querystart_value = req.qsparams.get(b"startID", None) else: # default is last 10 pushes query.querystart = QueryType.COUNT query.querystart_value = PUSHES_PER_PAGE if b"enddate" in req.qsparams: enddate = do_parse_date(req.qsparams.get(b"enddate", None)) query.queryend = QueryType.DATE query.queryend_value = enddate elif b"tochange" in req.qsparams: query.queryend = QueryType.CHANGESET query.queryend_value = req.qsparams.get(b"tochange", None) elif b"endID" in req.qsparams: query.queryend = QueryType.PUSHID query.queryend_value = req.qsparams.get(b"endID", None) query.userquery = req.qsparams.getall(b"user") # TODO: use rev here, switch page to ?page=foo ? query.changesetquery = req.qsparams.getall(b"changeset") query.branch = req.qsparams.get(b"branch") try: query.formatversion = int(req.qsparams.get(b"version", b"1")) except ValueError: raise ErrorResponse(500, b"version parameter must be an integer") if query.formatversion < 1 or query.formatversion > 2: raise ErrorResponse(500, b"version parameter must be 1 or 2") query.do_query() return query def isotime(timestamp): """Returns the ISO format of the given timestamp""" dt = datetime.utcfromtimestamp(timestamp).isoformat() dt = pycompat.bytestr(dt) return dt + b"Z" def feedentrygenerator(_context, entries, repo, url, urlbase): """Generator of mappings for pushlog feed entries field""" for pushid, user, date, node in entries: ctx = scmutil.revsingle(repo, node) filesgen = [{b"name": fn} for fn in ctx.files()] yield { b"node": pycompat.bytestr(node), b"date": isotime(date), b"user": xmlescape(pycompat.bytestr(user)), b"urlbase": pycompat.bytestr(urlbase), b"url": pycompat.bytestr(url), b"files": templateutil.mappinglist(filesgen), } def pushlog_feed(web): """WebCommand for producing the ATOM feed of the pushlog.""" req = web.req req.qsparams[b"style"] = b"atom" # Need to reset the templater instance to use the new style. web.tmpl = web.templater(req) query = pushlog_setup(web.repo, req) if query.entries: dt = pycompat.bytestr(isotime(query.entries[0][2])) else: dt = datetime.utcnow().isoformat().split(".", 1)[0] dt = pycompat.bytestr(dt) dt += b"Z" url = req.apppath or b"/" if not url.endswith(b"/"): url += b"/" queryentries = ( (pushid, user, date, node) for (pushid, user, date, node) in query.entries if scmutil.isrevsymbol(web.repo, node) ) data = { b"urlbase": query.urlbase, b"url": url, b"repo": query.reponame, b"date": dt, b"entries": templateutil.mappinggenerator( feedentrygenerator, args=(queryentries, web.repo, url, query.urlbase) ), } web.res.headers[b"Content-Type"] = ATOM_MIMETYPE return web.sendtemplate(b"pushlog", **pycompat.strkwargs(data)) def create_entry( ctx, web, pushid, user, date, node, mergehidden, parity, pushcount=None ): """Creates an entry to be yielded in the `changelist` generator `pushcount` will be non-None when we are generating an entry for the first change in a given push """ repo = web.repo n = ctx.node() ctxfiles = ctx.files() firstchange = pushcount is not None mergerollupval = templateutil.mappinglist( [{b"count": pushcount}] if firstchange and mergehidden == b"hidden" else [] ) pushval = templateutil.mappinglist( [{b"date": localdate(date), b"user": user}] if firstchange else [] ) filediffs = webutil.listfilediffs(ctxfiles, node, len(ctxfiles)) return { b"author": ctx.user(), b"desc": ctx.description(), b"files": filediffs, b"rev": ctx.rev(), b"node": hex(n), b"parents": [c.hex() for c in ctx.parents()], b"tags": webutil.nodetagsdict(repo, n), b"branches": webutil.nodebranchdict(repo, ctx), b"inbranch": webutil.nodeinbranch(repo, ctx), b"hidden": mergehidden, b"mergerollup": mergerollupval, b"id": pushid, b"parity": parity, b"push": pushval, } def handle_entries_for_push(web, samepush, p): """Displays pushlog changelist entries for a single push The main use of this function is to ensure the first changeset for a given push receives extra required information, namely the information needed to populate the `mergerollup` and `push` fields. These fields are only present on the first changeset in a push, and show the number of changesets merged in this push (if the push was a merge) and the user who pushed the change, respectively. """ pushcount = len(samepush) pushid, user, date, node = samepush.popleft() ctx = scmutil.revsingle(web.repo, node) multiple_parents = len([c for c in ctx.parents() if c.node() != nullid]) > 1 mergehidden = b"hidden" if multiple_parents else b"" # Yield the initial entry, which contains special information such as # the number of changesets merged in this push yield create_entry( ctx, web, pushid, user, date, node, mergehidden, p, pushcount=pushcount ) # Yield all other entries for the given push for pushid, user, date, node in samepush: ctx = scmutil.revsingle(web.repo, node) yield create_entry( ctx, web, pushid, user, date, node, mergehidden, p, pushcount=None ) def pushlog_changenav(_context, query): """Generator which yields changelist navigation fields for the pushlog""" numpages = int(ceil(query.totalentries / float(PUSHES_PER_PAGE))) start = max(1, query.page - PUSHES_PER_PAGE / 2) end = min(numpages + 1, query.page + PUSHES_PER_PAGE / 2) # Ensure `start` and `end` are int since they need to be passed to `range` start = int(ceil(start)) end = int(ceil(end)) if query.page != 1: yield {b"page": 1, b"label": b"First"} yield {b"page": query.page - 1, b"label": b"Prev"} for i in range(start, end): yield {b"page": i, b"label": pycompat.bytestr(i)} if query.page != numpages: yield {b"page": query.page + 1, b"label": b"Next"} yield {b"page": numpages, b"label": b"Last"} def pushlog_changelist(_context, web, query, tiponly): """Generator which yields a entries in a changelist for the pushlog""" parity = paritygen(web.stripecount) p = next(parity) # Iterate over query entries if we have not reached the limit and # the node is visible in the repo visiblequeryentries = ( (pushid, user, date, node) for pushid, user, date, node in query.entries if scmutil.isrevsymbol(web.repo, node) ) # FIFO queue. Accumulate pushes as we need to # count how many entries correspond with a given push samepush = collections.deque() # Get the first element of the query # return if there are no entries try: pushid, user, date, node = next(visiblequeryentries) lastid = pushid samepush.append((pushid, user, date, node)) except StopIteration: return # Iterate over all the non-hidden entries and aggregate # them together per unique pushid for allentry in visiblequeryentries: pushid, user, date, node = allentry # If the entries both come from the same push, add to the accumulated set of entries if pushid == lastid: samepush.append(allentry) # Once the pushid's are different, yield the result else: # If this is the first changeset for this push, put the change in the queue firstpush = len(samepush) == 0 if firstpush: samepush.append(allentry) for entry in handle_entries_for_push(web, samepush, p): yield entry if tiponly: return # Set the lastid lastid = pushid # Swap parity once we are on to processing another push p = next(parity) # Reset the aggregation of entries, as we are now processing a new push samepush = collections.deque() # If this was not the first push, the current entry needs processing # Add it to the queue here if not firstpush: samepush.append(allentry) # We don't need to display the remaining entries on the page if there are none if not samepush: return # Display the remaining entries for the page for entry in handle_entries_for_push(web, samepush, p): yield entry if tiponly: return def pushlog_html(web): """WebCommand for producing the HTML view of the pushlog.""" req = web.req query = pushlog_setup(web.repo, req) data = { b"changenav": templateutil.mappinggenerator(pushlog_changenav, args=(query,)), b"rev": 0, b"entries": templateutil.mappinggenerator( pushlog_changelist, args=(web, query, False) ), b"latestentry": templateutil.mappinggenerator( pushlog_changelist, args=(web, query, True) ), b"startdate": req.qsparams.get(b"startdate", b"1 week ago"), b"enddate": req.qsparams.get(b"enddate", b"now"), b"querydescription": query.description(), b"archives": web.archivelist(b"tip"), } return web.sendtemplate(b"pushlog", **pycompat.strkwargs(data)) def pushes_worker(query, repo, full): """Given a PushlogQuery, return a data structure mapping push IDs to a map of data about the push.""" haveobs = bool(repo.obsstore) pushes = {} for pushid, user, date, node in query.entries: pushid = pycompat.bytestr(pushid) # Create the pushes entry first. It is OK to have empty # pushes if nodes from the pushlog no longer exist. if pushid not in pushes: pushes[pushid] = { b"user": user, b"date": date, b"changesets": [], } try: ctx = repo[node] nodekey = b"changesets" # Changeset is hidden except error.FilteredRepoLookupError: # Try to find the hidden changeset so its metadata can be used. try: ctx = repo.unfiltered()[node] except error.LookupError: continue nodekey = b"obsoletechangesets" if full: node = { b"node": ctx.hex(), b"author": ctx.user(), b"desc": ctx.description(), b"branch": ctx.branch(), b"parents": [c.hex() for c in ctx.parents()], b"tags": ctx.tags(), b"files": ctx.files(), } # Only expose obsolescence metadata if the repo has some. if haveobs: precursors = repo.obsstore.predecessors.get(ctx.node(), ()) precursors = [hex(m[0]) for m in precursors] if precursors: node[b"precursors"] = precursors pushes[pushid].setdefault(nodekey, []).insert(0, node) return {b"pushes": pushes, b"lastpushid": query.lastpushid} def pushes(web): """WebCommand to return a data structure containing pushes.""" req = web.req query = pushlog_setup(web.repo, req) data = pushes_worker(query, web.repo, b"full" in req.qsparams) if query.formatversion == 1: template = b"pushes1" elif query.formatversion == 2: template = b"pushes2" else: raise ErrorResponse(500, b"unexpected formatversion") return web.sendtemplate(template, **pycompat.strkwargs(data)) hgwebcommands.pushlog = hgwebcommands.webcommand(b"pushlog")(pushlog_feed) hgwebcommands.pushloghtml = hgwebcommands.webcommand(b"pushloghtml")(pushlog_html) hgwebcommands.pushes = hgwebcommands.webcommand(b"pushes")(pushes)