chiark / gitweb /
lint: remove "no recommended build" check
[fdroidserver.git] / fdroidserver / lint.py
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3 #
4 # lint.py - part of the FDroid server tool
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 th
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public Licen
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 from argparse import ArgumentParser
21 import re
22 import logging
23 import common
24 import metadata
25 import sys
26 from collections import Counter
27 from sets import Set
28
29 config = None
30 options = None
31
32
33 def enforce_https(domain):
34     return (re.compile(r'.*[^sS]://[^/]*' + re.escape(domain) + r'(/.*)?'),
35             domain + " URLs should always use https://")
36
37 https_enforcings = [
38     enforce_https('github.com'),
39     enforce_https('gitlab.com'),
40     enforce_https('bitbucket.org'),
41     enforce_https('apache.org'),
42     enforce_https('google.com'),
43     enforce_https('svn.code.sf.net'),
44 ]
45
46
47 def forbid_shortener(domain):
48     return (re.compile(r'https?://[^/]*' + re.escape(domain) + r'/.*'),
49             "URL shorteners should not be used")
50
51 http_url_shorteners = [
52     forbid_shortener('goo.gl'),
53     forbid_shortener('t.co'),
54     forbid_shortener('ur1.ca'),
55 ]
56
57 http_warnings = https_enforcings + http_url_shorteners + [
58     (re.compile(r'.*github\.com/[^/]+/[^/]+\.git'),
59      "Appending .git is not necessary"),
60     (re.compile(r'(.*/blob/master/|.*raw\.github.com/[^/]*/[^/]*/master/)'),
61      "Use /HEAD/ instead of /master/ to point at a file in the default branch"),
62 ]
63
64 regex_warnings = {
65     'Web Site': http_warnings + [
66     ],
67     'Source Code': http_warnings + [
68     ],
69     'Repo': https_enforcings + [
70     ],
71     'Issue Tracker': http_warnings + [
72         (re.compile(r'.*github\.com/[^/]+/[^/]+[/]*$'),
73          "/issues is missing"),
74     ],
75     'Donate': http_warnings + [
76         (re.compile(r'.*flattr\.com'),
77          "Flattr donation methods belong in the FlattrID flag"),
78     ],
79     'Changelog': http_warnings + [
80     ],
81     'License': [
82         (re.compile(r'^(|None|Unknown)$'),
83          "No license specified"),
84     ],
85     'Summary': [
86         (re.compile(r'^$'),
87          "Summary yet to be filled"),
88         (re.compile(r'.*\b(free software|open source)\b.*', re.IGNORECASE),
89          "No need to specify that the app is Free Software"),
90         (re.compile(r'.*((your|for).*android|android.*(app|device|client|port|version))', re.IGNORECASE),
91          "No need to specify that the app is for Android"),
92         (re.compile(r'.*[a-z0-9][.!?]( |$)'),
93          "Punctuation should be avoided"),
94     ],
95     'Description': [
96         (re.compile(r'^No description available$'),
97          "Description yet to be filled"),
98         (re.compile(r'\s*[*#][^ .]'),
99          "Invalid bulleted list"),
100         (re.compile(r'^\s'),
101          "Unnecessary leading space"),
102         (re.compile(r'.*\s$'),
103          "Unnecessary trailing space"),
104         (re.compile(r'.*([^[]|^)\[[^:[\]]+( |\]|$)'),
105          "Invalid link - use [http://foo.bar Link title] or [http://foo.bar]"),
106         (re.compile(r'.*[^[]https?://[^ ]+'),
107          "Unlinkified link - use [http://foo.bar Link title] or [http://foo.bar]"),
108     ],
109 }
110
111 categories = Set([
112     "Connectivity",
113     "Development",
114     "Games",
115     "Graphics",
116     "Internet",
117     "Money",
118     "Multimedia",
119     "Navigation",
120     "Phone & SMS",
121     "Reading",
122     "Science & Education",
123     "Security",
124     "Sports & Health",
125     "System",
126     "Theming",
127     "Time",
128     "Writing",
129 ])
130
131 desc_url = re.compile("[^[]\[([^ ]+)( |\]|$)")
132
133
134 def main():
135
136     global config, options, curid, count
137     curid = None
138
139     count = Counter()
140
141     def warn(message):
142         global curid, count
143         if curid:
144             print "%s:" % curid
145             curid = None
146             count['app'] += 1
147         print '    %s' % message
148         count['warn'] += 1
149
150     # Parse command line...
151     parser = ArgumentParser(usage="%(prog)s [options] [APPID [APPID ...]]")
152     parser.add_argument("appid", nargs='*', help="app-id in the form APPID")
153     parser.add_argument("-v", "--verbose", action="store_true", default=False,
154                         help="Spew out even more information than normal")
155     parser.add_argument("-q", "--quiet", action="store_true", default=False,
156                         help="Restrict output to warnings and errors")
157     options = parser.parse_args()
158
159     config = common.read_config(options)
160
161     # Get all apps...
162     allapps = metadata.read_metadata(xref=True)
163     apps = common.read_app_args(options.appid, allapps, False)
164
165     filling_ucms = re.compile('^(Tags.*|RepoManifest.*)')
166
167     for appid, app in apps.iteritems():
168         if app['Disabled']:
169             continue
170
171         curid = appid
172         count['app_total'] += 1
173
174         lowest_vercode = -1
175         curbuild = None
176         for build in app['builds']:
177             if not build['disable']:
178                 vercode = int(build['vercode'])
179                 if lowest_vercode == -1 or vercode < lowest_vercode:
180                     lowest_vercode = vercode
181             if not curbuild or int(build['vercode']) > int(curbuild['vercode']):
182                 curbuild = build
183
184         # Incorrect UCM
185         if (curbuild and curbuild['commit']
186                 and app['Update Check Mode'] == 'RepoManifest'
187                 and not curbuild['commit'].startswith('unknown')
188                 and curbuild['vercode'] == app['Current Version Code']
189                 and not curbuild['forcevercode']
190                 and any(s in curbuild['commit'] for s in '.,_-/')):
191             warn("Last used commit '%s' looks like a tag, but Update Check Mode is '%s'" % (
192                 curbuild['commit'], app['Update Check Mode']))
193
194         # Summary size limit
195         summ_chars = len(app['Summary'])
196         if summ_chars > config['char_limits']['Summary']:
197             warn("Summary of length %s is over the %i char limit" % (
198                 summ_chars, config['char_limits']['Summary']))
199
200         # Redundant info
201         if app['Web Site'] and app['Source Code']:
202             if app['Web Site'].lower() == app['Source Code'].lower():
203                 warn("Website '%s' is just the app's source code link" % app['Web Site'])
204
205         # Old links
206         usual_sites = [
207             'github.com',
208             'gitlab.com',
209             'bitbucket.org',
210         ]
211         old_sites = [
212             'gitorious.org',
213             'code.google.com',
214         ]
215         if any(s in app['Repo'] for s in usual_sites):
216             for f in ['Web Site', 'Source Code', 'Issue Tracker', 'Changelog']:
217                 if any(s in app[f] for s in old_sites):
218                     warn("App is in '%s' but has a link to '%s'" % (app['Repo'], app[f]))
219
220         if filling_ucms.match(app['Update Check Mode']):
221             if all(app[f] == metadata.app_defaults[f] for f in [
222                     'Auto Name',
223                     'Current Version',
224                     'Current Version Code',
225                     ]):
226                 warn("UCM is set but it looks like checkupdates hasn't been run yet")
227
228         if app['Update Check Name'] == appid:
229             warn("Update Check Name is set to the known app id - it can be removed")
230
231         # Missing or incorrect categories
232         if not app['Categories']:
233             warn("Categories are not set")
234         for categ in app['Categories']:
235             if categ not in categories:
236                 warn("Category '%s' is not valid" % categ)
237
238         if app['Name'] and app['Name'] == app['Auto Name']:
239             warn("Name '%s' is just the auto name" % app['Name'])
240
241         name = app['Name'] or app['Auto Name']
242         if app['Summary'] and name:
243             if app['Summary'].lower() == name.lower():
244                 warn("Summary '%s' is just the app's name" % app['Summary'])
245
246         desc = app['Description']
247         if app['Summary'] and desc and len(desc) == 1:
248             if app['Summary'].lower() == desc[0].lower():
249                 warn("Description '%s' is just the app's summary" % app['Summary'])
250
251         # Description size limit
252         desc_charcount = sum(len(l) for l in desc)
253         if desc_charcount > config['char_limits']['Description']:
254             warn("Description of length %s is over the %i char limit" % (
255                 desc_charcount, config['char_limits']['Description']))
256
257         maxcols = 140
258         for l in app['Description']:
259             if any(l.startswith(c) for c in ['*', '#']):
260                 continue
261             if any(len(w) > maxcols for w in l.split(' ')):
262                 continue
263             if len(l) > maxcols:
264                 warn("Description should be wrapped to 80-120 chars")
265                 break
266
267         if (not desc[0] or not desc[-1]
268                 or any(not desc[l - 1] and not desc[l] for l in range(1, len(desc)))):
269             warn("Description has an extra empty line")
270
271         seenlines = set()
272         for l in app['Description']:
273             if len(l) < 1:
274                 continue
275             if l in seenlines:
276                 warn("Description has a duplicate line")
277             seenlines.add(l)
278
279         for l in app['Description']:
280             for um in desc_url.finditer(l):
281                 url = um.group(1)
282                 for m, r in http_warnings:
283                     if m.match(url):
284                         warn("URL '%s' in Description: %s" % (url, r))
285
286         # Check for lists using the wrong characters
287         validchars = ['*', '#']
288         lchar = ''
289         lcount = 0
290         for l in app['Description']:
291             if len(l) < 1:
292                 lcount = 0
293                 continue
294
295             if l[0] == lchar and l[1] == ' ':
296                 lcount += 1
297                 if lcount > 2 and lchar not in validchars:
298                     warn("Description has a list (%s) but it isn't bulleted (*) nor numbered (#)" % lchar)
299                     break
300             else:
301                 lchar = l[0]
302                 lcount = 1
303
304         # Regex checks in all kinds of fields
305         for f in regex_warnings:
306             for m, r in regex_warnings[f]:
307                 v = app[f]
308                 if type(v) == str:
309                     if v is None:
310                         continue
311                     if m.match(v):
312                         warn("%s '%s': %s" % (f, v, r))
313                 elif type(v) == list:
314                     for l in v:
315                         if m.match(l):
316                             warn("%s at line '%s': %s" % (f, l, r))
317
318         # Build warnings
319         for build in app['builds']:
320             if build['disable']:
321                 continue
322             for s in ['master', 'origin', 'HEAD', 'default', 'trunk']:
323                 if build['commit'] and build['commit'].startswith(s):
324                     warn("Branch '%s' used as commit in build '%s'" % (
325                         s, build['version']))
326                 for srclib in build['srclibs']:
327                     ref = srclib.split('@')[1].split('/')[0]
328                     if ref.startswith(s):
329                         warn("Branch '%s' used as commit in srclib '%s'" % (
330                             s, srclib))
331
332         if not curid:
333             print
334
335     if count['warn'] > 0:
336         logging.warn("Found a total of %i warnings in %i apps out of %i total." % (
337             count['warn'], count['app'], count['app_total']))
338         sys.exit(1)
339
340
341 if __name__ == "__main__":
342     main()