chiark / gitweb /
build/checkupdates/update: log current fdroiddata commit to wiki
[fdroidserver.git] / fdroidserver / checkupdates.py
1 #!/usr/bin/env python3
2 #
3 # checkupdates.py - part of the FDroid server tools
4 # Copyright (C) 2010-2015, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 import os
21 import re
22 import urllib.request
23 import urllib.error
24 import time
25 import subprocess
26 import sys
27 from argparse import ArgumentParser
28 import traceback
29 import html
30 from distutils.version import LooseVersion
31 import logging
32 import copy
33 import urllib.parse
34
35 from . import _
36 from . import common
37 from . import metadata
38 from .exception import VCSException, NoSubmodulesException, FDroidException, MetaDataException
39
40
41 # Check for a new version by looking at a document retrieved via HTTP.
42 # The app's Update Check Data field is used to provide the information
43 # required.
44 def check_http(app):
45
46     try:
47
48         if not app.UpdateCheckData:
49             raise FDroidException('Missing Update Check Data')
50
51         urlcode, codeex, urlver, verex = app.UpdateCheckData.split('|')
52         parsed = urllib.parse.urlparse(urlcode)
53         if not parsed.netloc or not parsed.scheme or parsed.scheme != 'https':
54             raise FDroidException(_('UpdateCheckData has invalid URL: {url}').format(url=urlcode))
55         if urlver != '.':
56             parsed = urllib.parse.urlparse(urlver)
57             if not parsed.netloc or not parsed.scheme or parsed.scheme != 'https':
58                 raise FDroidException(_('UpdateCheckData has invalid URL: {url}').format(url=urlcode))
59
60         vercode = "99999999"
61         if len(urlcode) > 0:
62             logging.debug("...requesting {0}".format(urlcode))
63             req = urllib.request.Request(urlcode, None)
64             resp = urllib.request.urlopen(req, None, 20)
65             page = resp.read().decode('utf-8')
66
67             m = re.search(codeex, page)
68             if not m:
69                 raise FDroidException("No RE match for version code")
70             vercode = m.group(1).strip()
71
72         version = "??"
73         if len(urlver) > 0:
74             if urlver != '.':
75                 logging.debug("...requesting {0}".format(urlver))
76                 req = urllib.request.Request(urlver, None)
77                 resp = urllib.request.urlopen(req, None, 20)
78                 page = resp.read().decode('utf-8')
79
80             m = re.search(verex, page)
81             if not m:
82                 raise FDroidException("No RE match for version")
83             version = m.group(1)
84
85         return (version, vercode)
86
87     except FDroidException:
88         msg = "Could not complete http check for app {0} due to unknown error: {1}".format(app.id, traceback.format_exc())
89         return (None, msg)
90
91
92 # Check for a new version by looking at the tags in the source repo.
93 # Whether this can be used reliably or not depends on
94 # the development procedures used by the project's developers. Use it with
95 # caution, because it's inappropriate for many projects.
96 # Returns (None, "a message") if this didn't work, or (version, vercode, tag) for
97 # the details of the current version.
98 def check_tags(app, pattern):
99
100     try:
101
102         if app.RepoType == 'srclib':
103             build_dir = os.path.join('build', 'srclib', app.Repo)
104             repotype = common.getsrclibvcs(app.Repo)
105         else:
106             build_dir = os.path.join('build', app.id)
107             repotype = app.RepoType
108
109         if repotype not in ('git', 'git-svn', 'hg', 'bzr'):
110             return (None, 'Tags update mode only works for git, hg, bzr and git-svn repositories currently', None)
111
112         if repotype == 'git-svn' and ';' not in app.Repo:
113             return (None, 'Tags update mode used in git-svn, but the repo was not set up with tags', None)
114
115         # Set up vcs interface and make sure we have the latest code...
116         vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
117
118         vcs.gotorevision(None)
119
120         last_build = app.get_last_build()
121
122         try_init_submodules(app, last_build, vcs)
123
124         hpak = None
125         htag = None
126         hver = None
127         hcode = "0"
128
129         tags = []
130         if repotype == 'git':
131             tags = vcs.latesttags()
132         else:
133             tags = vcs.gettags()
134         if not tags:
135             return (None, "No tags found", None)
136
137         logging.debug("All tags: " + ','.join(tags))
138         if pattern:
139             pat = re.compile(pattern)
140             tags = [tag for tag in tags if pat.match(tag)]
141             if not tags:
142                 return (None, "No matching tags found", None)
143             logging.debug("Matching tags: " + ','.join(tags))
144
145         if len(tags) > 5 and repotype == 'git':
146             tags = tags[:5]
147             logging.debug("Latest tags: " + ','.join(tags))
148
149         for tag in tags:
150             logging.debug("Check tag: '{0}'".format(tag))
151             vcs.gotorevision(tag)
152
153             for subdir in possible_subdirs(app):
154                 if subdir == '.':
155                     root_dir = build_dir
156                 else:
157                     root_dir = os.path.join(build_dir, subdir)
158                 paths = common.manifest_paths(root_dir, last_build.gradle)
159                 version, vercode, package = common.parse_androidmanifests(paths, app)
160                 if vercode:
161                     logging.debug("Manifest exists in subdir '{0}'. Found version {1} ({2})"
162                                   .format(subdir, version, vercode))
163                     if int(vercode) > int(hcode):
164                         hpak = package
165                         htag = tag
166                         hcode = str(int(vercode))
167                         hver = version
168
169         if not hpak:
170             return (None, "Couldn't find package ID", None)
171         if hver:
172             return (hver, hcode, htag)
173         return (None, "Couldn't find any version information", None)
174
175     except VCSException as vcse:
176         msg = "VCS error while scanning app {0}: {1}".format(app.id, vcse)
177         return (None, msg, None)
178     except Exception:
179         msg = "Could not scan app {0} due to unknown error: {1}".format(app.id, traceback.format_exc())
180         return (None, msg, None)
181
182
183 # Check for a new version by looking at the AndroidManifest.xml at the HEAD
184 # of the source repo. Whether this can be used reliably or not depends on
185 # the development procedures used by the project's developers. Use it with
186 # caution, because it's inappropriate for many projects.
187 # Returns (None, "a message") if this didn't work, or (version, vercode) for
188 # the details of the current version.
189 def check_repomanifest(app, branch=None):
190
191     try:
192
193         if app.RepoType == 'srclib':
194             build_dir = os.path.join('build', 'srclib', app.Repo)
195             repotype = common.getsrclibvcs(app.Repo)
196         else:
197             build_dir = os.path.join('build', app.id)
198             repotype = app.RepoType
199
200         # Set up vcs interface and make sure we have the latest code...
201         vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
202
203         if repotype == 'git':
204             if branch:
205                 branch = 'origin/' + branch
206             vcs.gotorevision(branch)
207         elif repotype == 'git-svn':
208             vcs.gotorevision(branch)
209         elif repotype == 'hg':
210             vcs.gotorevision(branch)
211         elif repotype == 'bzr':
212             vcs.gotorevision(None)
213
214         last_build = metadata.Build()
215         if len(app.builds) > 0:
216             last_build = app.builds[-1]
217
218         try_init_submodules(app, last_build, vcs)
219
220         hpak = None
221         hver = None
222         hcode = "0"
223         for subdir in possible_subdirs(app):
224             if subdir == '.':
225                 root_dir = build_dir
226             else:
227                 root_dir = os.path.join(build_dir, subdir)
228             paths = common.manifest_paths(root_dir, last_build.gradle)
229             version, vercode, package = common.parse_androidmanifests(paths, app)
230             if vercode:
231                 logging.debug("Manifest exists in subdir '{0}'. Found version {1} ({2})"
232                               .format(subdir, version, vercode))
233                 if int(vercode) > int(hcode):
234                     hpak = package
235                     hcode = str(int(vercode))
236                     hver = version
237
238         if not hpak:
239             return (None, "Couldn't find package ID")
240         if hver:
241             return (hver, hcode)
242         return (None, "Couldn't find any version information")
243
244     except VCSException as vcse:
245         msg = "VCS error while scanning app {0}: {1}".format(app.id, vcse)
246         return (None, msg)
247     except Exception:
248         msg = "Could not scan app {0} due to unknown error: {1}".format(app.id, traceback.format_exc())
249         return (None, msg)
250
251
252 def check_repotrunk(app):
253
254     try:
255         if app.RepoType == 'srclib':
256             build_dir = os.path.join('build', 'srclib', app.Repo)
257             repotype = common.getsrclibvcs(app.Repo)
258         else:
259             build_dir = os.path.join('build', app.id)
260             repotype = app.RepoType
261
262         if repotype not in ('git-svn', ):
263             return (None, 'RepoTrunk update mode only makes sense in git-svn repositories')
264
265         # Set up vcs interface and make sure we have the latest code...
266         vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
267
268         vcs.gotorevision(None)
269
270         ref = vcs.getref()
271         return (ref, ref)
272     except VCSException as vcse:
273         msg = "VCS error while scanning app {0}: {1}".format(app.id, vcse)
274         return (None, msg)
275     except Exception:
276         msg = "Could not scan app {0} due to unknown error: {1}".format(app.id, traceback.format_exc())
277         return (None, msg)
278
279
280 # Check for a new version by looking at the Google Play Store.
281 # Returns (None, "a message") if this didn't work, or (version, None) for
282 # the details of the current version.
283 def check_gplay(app):
284     time.sleep(15)
285     url = 'https://play.google.com/store/apps/details?id=' + app.id
286     headers = {'User-Agent': 'Mozilla/5.0 (X11; Linux i686; rv:18.0) Gecko/20100101 Firefox/18.0'}
287     req = urllib.request.Request(url, None, headers)
288     try:
289         resp = urllib.request.urlopen(req, None, 20)
290         page = resp.read().decode()
291     except urllib.error.HTTPError as e:
292         return (None, str(e.code))
293     except Exception as e:
294         return (None, 'Failed:' + str(e))
295
296     version = None
297
298     m = re.search('itemprop="softwareVersion">[ ]*([^<]+)[ ]*</div>', page)
299     if m:
300         version = html.unescape(m.group(1))
301
302     if version == 'Varies with device':
303         return (None, 'Device-variable version, cannot use this method')
304
305     if not version:
306         return (None, "Couldn't find version")
307     return (version.strip(), None)
308
309
310 def try_init_submodules(app, last_build, vcs):
311     """Try to init submodules if the last build entry used them.
312     They might have been removed from the app's repo in the meantime,
313     so if we can't find any submodules we continue with the updates check.
314     If there is any other error in initializing them then we stop the check.
315     """
316     if last_build.submodules:
317         try:
318             vcs.initsubmodules()
319         except NoSubmodulesException:
320             logging.info("No submodules present for {}".format(app.Name))
321
322
323 # Return all directories under startdir that contain any of the manifest
324 # files, and thus are probably an Android project.
325 def dirs_with_manifest(startdir):
326     for root, dirs, files in os.walk(startdir):
327         if any(m in files for m in [
328                 'AndroidManifest.xml', 'pom.xml', 'build.gradle']):
329             yield root
330
331
332 # Tries to find a new subdir starting from the root build_dir. Returns said
333 # subdir relative to the build dir if found, None otherwise.
334 def possible_subdirs(app):
335
336     if app.RepoType == 'srclib':
337         build_dir = os.path.join('build', 'srclib', app.Repo)
338     else:
339         build_dir = os.path.join('build', app.id)
340
341     last_build = app.get_last_build()
342
343     for d in dirs_with_manifest(build_dir):
344         m_paths = common.manifest_paths(d, last_build.gradle)
345         package = common.parse_androidmanifests(m_paths, app)[2]
346         if package is not None:
347             subdir = os.path.relpath(d, build_dir)
348             logging.debug("Adding possible subdir %s" % subdir)
349             yield subdir
350
351
352 def fetch_autoname(app, tag):
353
354     if not app.RepoType or app.UpdateCheckMode in ('None', 'Static'):
355         return None
356
357     if app.RepoType == 'srclib':
358         build_dir = os.path.join('build', 'srclib', app.Repo)
359     else:
360         build_dir = os.path.join('build', app.id)
361
362     try:
363         vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
364         vcs.gotorevision(tag)
365     except VCSException:
366         return None
367
368     last_build = app.get_last_build()
369
370     logging.debug("...fetch auto name from " + build_dir)
371     new_name = None
372     for subdir in possible_subdirs(app):
373         if subdir == '.':
374             root_dir = build_dir
375         else:
376             root_dir = os.path.join(build_dir, subdir)
377         new_name = common.fetch_real_name(root_dir, last_build.gradle)
378         if new_name is not None:
379             break
380     commitmsg = None
381     if new_name:
382         logging.debug("...got autoname '" + new_name + "'")
383         if new_name != app.AutoName:
384             app.AutoName = new_name
385             if not commitmsg:
386                 commitmsg = "Set autoname of {0}".format(common.getappname(app))
387     else:
388         logging.debug("...couldn't get autoname")
389
390     return commitmsg
391
392
393 def checkupdates_app(app):
394
395     # If a change is made, commitmsg should be set to a description of it.
396     # Only if this is set will changes be written back to the metadata.
397     commitmsg = None
398
399     tag = None
400     msg = None
401     vercode = None
402     noverok = False
403     mode = app.UpdateCheckMode
404     if mode.startswith('Tags'):
405         pattern = mode[5:] if len(mode) > 4 else None
406         (version, vercode, tag) = check_tags(app, pattern)
407         if version == 'Unknown':
408             version = tag
409         msg = vercode
410     elif mode == 'RepoManifest':
411         (version, vercode) = check_repomanifest(app)
412         msg = vercode
413     elif mode.startswith('RepoManifest/'):
414         tag = mode[13:]
415         (version, vercode) = check_repomanifest(app, tag)
416         msg = vercode
417     elif mode == 'RepoTrunk':
418         (version, vercode) = check_repotrunk(app)
419         msg = vercode
420     elif mode == 'HTTP':
421         (version, vercode) = check_http(app)
422         msg = vercode
423     elif mode in ('None', 'Static'):
424         version = None
425         msg = 'Checking disabled'
426         noverok = True
427     else:
428         version = None
429         msg = 'Invalid update check method'
430
431     if version and vercode and app.VercodeOperation:
432         if not common.VERCODE_OPERATION_RE.match(app.VercodeOperation):
433             raise MetaDataException(_('Invalid VercodeOperation: {field}')
434                                     .format(field=app.VercodeOperation))
435         oldvercode = str(int(vercode))
436         op = app.VercodeOperation.replace("%c", oldvercode)
437         vercode = str(eval(op))
438         logging.debug("Applied vercode operation: %s -> %s" % (oldvercode, vercode))
439
440     if version and any(version.startswith(s) for s in [
441             '${',  # Gradle variable names
442             '@string/',  # Strings we could not resolve
443             ]):
444         version = "Unknown"
445
446     updating = False
447     if version is None:
448         logmsg = "...{0} : {1}".format(app.id, msg)
449         if noverok:
450             logging.info(logmsg)
451         else:
452             logging.warn(logmsg)
453     elif vercode == app.CurrentVersionCode:
454         logging.info("...up to date")
455     else:
456         logging.debug("...updating - old vercode={0}, new vercode={1}".format(
457             app.CurrentVersionCode, vercode))
458         app.CurrentVersion = version
459         app.CurrentVersionCode = str(int(vercode))
460         updating = True
461
462     commitmsg = fetch_autoname(app, tag)
463
464     if updating:
465         name = common.getappname(app)
466         ver = common.getcvname(app)
467         logging.info('...updating to version %s' % ver)
468         commitmsg = 'Update CV of %s to %s' % (name, ver)
469
470     if options.auto:
471         mode = app.AutoUpdateMode
472         if not app.CurrentVersionCode:
473             logging.warn("Can't auto-update app with no current version code: " + app.id)
474         elif mode in ('None', 'Static'):
475             pass
476         elif mode.startswith('Version '):
477             pattern = mode[8:]
478             if pattern.startswith('+'):
479                 try:
480                     suffix, pattern = pattern.split(' ', 1)
481                 except ValueError:
482                     raise MetaDataException("Invalid AUM: " + mode)
483             else:
484                 suffix = ''
485             gotcur = False
486             latest = None
487             for build in app.builds:
488                 if int(build.versionCode) >= int(app.CurrentVersionCode):
489                     gotcur = True
490                 if not latest or int(build.versionCode) > int(latest.versionCode):
491                     latest = build
492
493             if int(latest.versionCode) > int(app.CurrentVersionCode):
494                 logging.info("Refusing to auto update, since the latest build is newer")
495
496             if not gotcur:
497                 newbuild = copy.deepcopy(latest)
498                 newbuild.disable = False
499                 newbuild.versionCode = app.CurrentVersionCode
500                 newbuild.versionName = app.CurrentVersion + suffix
501                 logging.info("...auto-generating build for " + newbuild.versionName)
502                 commit = pattern.replace('%v', newbuild.versionName)
503                 commit = commit.replace('%c', newbuild.versionCode)
504                 newbuild.commit = commit
505                 app.builds.append(newbuild)
506                 name = common.getappname(app)
507                 ver = common.getcvname(app)
508                 commitmsg = "Update %s to %s" % (name, ver)
509         else:
510             logging.warn('Invalid auto update mode "' + mode + '" on ' + app.id)
511
512     if commitmsg:
513         metadatapath = os.path.join('metadata', app.id + '.txt')
514         metadata.write_metadata(metadatapath, app)
515         if options.commit:
516             logging.info("Commiting update for " + metadatapath)
517             gitcmd = ["git", "commit", "-m", commitmsg]
518             if 'auto_author' in config:
519                 gitcmd.extend(['--author', config['auto_author']])
520             gitcmd.extend(["--", metadatapath])
521             if subprocess.call(gitcmd) != 0:
522                 raise FDroidException("Git commit failed")
523
524
525 def update_wiki(gplaylog, locallog):
526     if config.get('wiki_server') and config.get('wiki_path'):
527         try:
528             import mwclient
529             site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
530                                  path=config['wiki_path'])
531             site.login(config['wiki_user'], config['wiki_password'])
532
533             # Write a page with the last build log for this version code
534             wiki_page_path = 'checkupdates_' + time.strftime('%s', start_timestamp)
535             newpage = site.Pages[wiki_page_path]
536             txt = ''
537             txt += "* command line: <code>" + ' '.join(sys.argv) + "</code>\n"
538             txt += common.get_git_describe_link()
539             txt += "* started at " + common.get_wiki_timestamp(start_timestamp) + '\n'
540             txt += "* completed at " + common.get_wiki_timestamp() + '\n'
541             txt += "\n\n"
542             txt += common.get_android_tools_version_log()
543             txt += "\n\n"
544             if gplaylog:
545                 txt += '== --gplay check ==\n\n'
546                 txt += gplaylog
547             if locallog:
548                 txt += '== local source check ==\n\n'
549                 txt += locallog
550             newpage.save(txt, summary='Run log')
551             newpage = site.Pages['checkupdates']
552             newpage.save('#REDIRECT [[' + wiki_page_path + ']]', summary='Update redirect')
553         except Exception as e:
554             logging.error(_('Error while attempting to publish log: %s') % e)
555
556
557 config = None
558 options = None
559 start_timestamp = time.gmtime()
560
561
562 def main():
563
564     global config, options
565
566     # Parse command line...
567     parser = ArgumentParser(usage="%(prog)s [options] [APPID [APPID ...]]")
568     common.setup_global_opts(parser)
569     parser.add_argument("appid", nargs='*', help=_("applicationId to check for updates"))
570     parser.add_argument("--auto", action="store_true", default=False,
571                         help=_("Process auto-updates"))
572     parser.add_argument("--autoonly", action="store_true", default=False,
573                         help=_("Only process apps with auto-updates"))
574     parser.add_argument("--commit", action="store_true", default=False,
575                         help=_("Commit changes"))
576     parser.add_argument("--allow-dirty", action="store_true", default=False,
577                         help=_("Run on git repo that has uncommitted changes"))
578     parser.add_argument("--gplay", action="store_true", default=False,
579                         help=_("Only print differences with the Play Store"))
580     metadata.add_metadata_arguments(parser)
581     options = parser.parse_args()
582     metadata.warnings_action = options.W
583
584     config = common.read_config(options)
585
586     if not options.allow_dirty:
587         status = subprocess.check_output(['git', 'status', '--porcelain'])
588         if status:
589             logging.error(_('Build metadata git repo has uncommited changes!'))
590             sys.exit(1)
591
592     # Get all apps...
593     allapps = metadata.read_metadata()
594
595     apps = common.read_app_args(options.appid, allapps, False)
596
597     gplaylog = ''
598     if options.gplay:
599         for appid, app in apps.items():
600             gplaylog += '* ' + appid + '\n'
601             version, reason = check_gplay(app)
602             if version is None:
603                 if reason == '404':
604                     logging.info("{0} is not in the Play Store".format(common.getappname(app)))
605                 else:
606                     logging.info("{0} encountered a problem: {1}".format(common.getappname(app), reason))
607             if version is not None:
608                 stored = app.CurrentVersion
609                 if not stored:
610                     logging.info("{0} has no Current Version but has version {1} on the Play Store"
611                                  .format(common.getappname(app), version))
612                 elif LooseVersion(stored) < LooseVersion(version):
613                     logging.info("{0} has version {1} on the Play Store, which is bigger than {2}"
614                                  .format(common.getappname(app), version, stored))
615                 else:
616                     if stored != version:
617                         logging.info("{0} has version {1} on the Play Store, which differs from {2}"
618                                      .format(common.getappname(app), version, stored))
619                     else:
620                         logging.info("{0} has the same version {1} on the Play Store"
621                                      .format(common.getappname(app), version))
622         update_wiki(gplaylog, None)
623         return
624
625     locallog = ''
626     for appid, app in apps.items():
627
628         if options.autoonly and app.AutoUpdateMode in ('None', 'Static'):
629             logging.debug(_("Nothing to do for {appid}.").format(appid=appid))
630             continue
631
632         msg = _("Processing {appid}").format(appid=appid)
633         logging.info(msg)
634         locallog += '* ' + msg + '\n'
635
636         try:
637             checkupdates_app(app)
638         except Exception as e:
639             msg = _("...checkupdate failed for {appid} : {error}").format(appid=appid, error=e)
640             logging.error(msg)
641             locallog += msg + '\n'
642
643     update_wiki(None, locallog)
644
645     logging.info(_("Finished"))
646
647
648 if __name__ == "__main__":
649     main()