GAnarchy is a Project Page Generator focused on giving forks of a project the same visibility as the original repo. https://ganarchy.autistic.space/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

602 lines
26KB

  1. #!/usr/bin/env python3
  2. # GAnarchy - project homepage generator
  3. # Copyright (C) 2019 Soni L.
  4. #
  5. # This program is free software: you can redistribute it and/or modify
  6. # it under the terms of the GNU Affero General Public License as published by
  7. # the Free Software Foundation, either version 3 of the License, or
  8. # (at your option) any later version.
  9. #
  10. # This program is distributed in the hope that it will be useful,
  11. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. # GNU Affero General Public License for more details.
  14. #
  15. # You should have received a copy of the GNU Affero General Public License
  16. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  17. import sqlite3
  18. import click
  19. import os
  20. import subprocess
  21. import hashlib
  22. import hmac
  23. import jinja2
  24. import re
  25. import qtoml
  26. from collections import defaultdict
  27. from urllib.parse import urlparse
  28. MIGRATIONS = {
  29. "toml-config": (
  30. (
  31. '''UPDATE "repo_history" SET "project" = (SELECT "git_commit" FROM "config") WHERE "project" IS NULL''',
  32. '''ALTER TABLE "repos" RENAME TO "repos_old"''',),
  33. (
  34. '''UPDATE "repo_history" SET "project" = NULL WHERE "project" = (SELECT "git_commit" FROM "config")''',
  35. '''ALTER TABLE "repos_old" RENAME TO "repos"''',),
  36. "switches to toml config format. the old 'repos' table is preserved as 'repos_old'"
  37. ),
  38. "better-project-management": (
  39. (
  40. '''ALTER TABLE "repos" ADD COLUMN "branch" TEXT''',
  41. '''ALTER TABLE "repos" ADD COLUMN "project" TEXT''',
  42. '''CREATE UNIQUE INDEX "repos_url_branch_project" ON "repos" ("url", "branch", "project")''',
  43. '''CREATE INDEX "repos_project" ON "repos" ("project")''',
  44. '''ALTER TABLE "repo_history" ADD COLUMN "branch" TEXT''',
  45. '''ALTER TABLE "repo_history" ADD COLUMN "project" TEXT''',
  46. '''CREATE INDEX "repo_history_url_branch_project" ON "repo_history" ("url", "branch", "project")''',),
  47. (
  48. '''DELETE FROM "repos" WHERE "branch" IS NOT NULL OR "project" IS NOT NULL''',
  49. '''DELETE FROM "repo_history" WHERE "branch" IS NOT NULL OR "project" IS NOT NULL''',),
  50. "supports multiple projects, and allows choosing non-default branches"
  51. ),
  52. "test": (
  53. ('''-- apply''',),
  54. ('''-- revert''',),
  55. "does nothing"
  56. )
  57. }
  58. data_home = os.environ.get('XDG_DATA_HOME', '')
  59. if not data_home:
  60. data_home = os.environ['HOME'] + '/.local/share'
  61. data_home = data_home + "/ganarchy"
  62. cache_home = os.environ.get('XDG_CACHE_HOME', '')
  63. if not cache_home:
  64. cache_home = os.environ['HOME'] + '/.cache'
  65. cache_home = cache_home + "/ganarchy"
  66. config_home = os.environ.get('XDG_CONFIG_HOME', '')
  67. if not config_home:
  68. config_home = os.environ['HOME'] + '/.config'
  69. config_home = config_home + "/ganarchy"
  70. config_dirs = os.environ.get('XDG_CONFIG_DIRS', '')
  71. if not config_dirs:
  72. config_dirs = '/etc/xdg'
  73. # TODO check if this is correct
  74. config_dirs = [config_dir + "/ganarchy" for config_dir in config_dirs.split(':')]
  75. def get_template_loader():
  76. from jinja2 import DictLoader, FileSystemLoader, ChoiceLoader
  77. return ChoiceLoader([
  78. FileSystemLoader([config_home + "/templates"] + [config_dir + "/templates" for config_dir in config_dirs]),
  79. DictLoader({
  80. ## index.html
  81. 'index.html': """<!DOCTYPE html>
  82. <html lang="en">
  83. <head>
  84. <meta charset="utf-8" />
  85. <!--
  86. GAnarchy - project homepage generator
  87. Copyright (C) 2019 Soni L.
  88. This program is free software: you can redistribute it and/or modify
  89. it under the terms of the GNU General Public License as published by
  90. the Free Software Foundation, either version 3 of the License, or
  91. (at your option) any later version.
  92. This program is distributed in the hope that it will be useful,
  93. but WITHOUT ANY WARRANTY; without even the implied warranty of
  94. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  95. GNU General Public License for more details.
  96. You should have received a copy of the GNU General Public License
  97. along with this program. If not, see <https://www.gnu.org/licenses/>.
  98. -->
  99. <title>{{ ganarchy.title|e }}</title>
  100. <meta name="description" content="{{ ganarchy.title|e }}" />
  101. <!--if your browser doesn't like the following, use a different browser.-->
  102. <script type="application/javascript" src="/index.js"></script>
  103. </head>
  104. <body>
  105. <h1>{{ ganarchy.title|e }}</h1>
  106. <p>This is {{ ganarchy.title|e }}. Currently tracking the following projects:</p>
  107. <ul>
  108. {% for project in ganarchy.projects -%}
  109. <li><a href="/project/{{ project.commit|e }}">{{ project.title|e }}</a>: {{ project.description|e }}</li>
  110. {% endfor -%}
  111. </ul>
  112. <p>Powered by <a href="https://ganarchy.autistic.space/">GAnarchy</a>. AGPLv3-licensed. <a href="https://cybre.tech/SoniEx2/ganarchy">Source Code</a>.</p>
  113. <p>
  114. <a href="{{ ganarchy.base_url|e }}" onclick="event.preventDefault(); navigator.registerProtocolHandler('web+ganarchy', this.href + '?url=%s', 'GAnarchy');">Register web+ganarchy: URI handler</a>.
  115. </p>
  116. </body>
  117. </html>
  118. """,
  119. ## index.toml
  120. 'index.toml': """# Generated by GAnarchy
  121. {%- for project, repos in config.projects.items() %}
  122. [projects.{{project}}]
  123. {%- for repo_url, branches in repos.items() %}{% for branch, options in branches.items() %}{% if options.active %}
  124. "{{repo_url|tomle}}".{% if branch %}"{{branch|tomle}}"{% else %}HEAD{% endif %} = { active=true }
  125. {%- endif %}{% endfor %}
  126. {%- endfor %}
  127. {% endfor -%}
  128. """,
  129. ## project.html FIXME
  130. 'project.html': """<!DOCTYPE html>
  131. <html lang="en">
  132. <head>
  133. <meta charset="utf-8" />
  134. <!--
  135. GAnarchy - project homepage generator
  136. Copyright (C) 2019 Soni L.
  137. This program is free software: you can redistribute it and/or modify
  138. it under the terms of the GNU General Public License as published by
  139. the Free Software Foundation, either version 3 of the License, or
  140. (at your option) any later version.
  141. This program is distributed in the hope that it will be useful,
  142. but WITHOUT ANY WARRANTY; without even the implied warranty of
  143. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  144. GNU General Public License for more details.
  145. You should have received a copy of the GNU General Public License
  146. along with this program. If not, see <https://www.gnu.org/licenses/>.
  147. -->
  148. <title>{{ project_title|e }}</title>
  149. {% if project_desc %}<meta name="description" content="{{ project_desc|e }}" />{% endif %}
  150. <style type="text/css">.branchname { color: #808080; font-style: italic; }</style>
  151. </head>
  152. <body>
  153. <h1>{{ project_title|e }}</h1>
  154. <p>Tracking <span id="project_commit"><a href="web+ganarchy:{{ project_commit }}">{{ project_commit }}</a></span></p>
  155. <div id="project_body"><p>{{ project_body|e|replace("\n\n", "</p><p>") }}</p></div>
  156. <ul>
  157. {% for url, msg, img, branch in repos -%}
  158. <li><a href="{{ url|e }}">{{ url|e }}</a>{% if branch %} <span class="branchname">[{{ branch|e }}]</span>{% endif %}: {{ msg|e }}</li>
  159. {% endfor -%}
  160. </ul>
  161. <p>Powered by <a href="https://ganarchy.autistic.space/">GAnarchy</a>. AGPLv3-licensed. <a href="https://cybre.tech/SoniEx2/ganarchy">Source Code</a>.</p>
  162. <p>
  163. <a href="/">Main page</a>.
  164. <a href="{{ base_url|e }}" onclick="event.preventDefault(); navigator.registerProtocolHandler('web+ganarchy', this.href + '?url=%s', 'GAnarchy');">Register web+ganarchy: URI handler</a>.
  165. </p>
  166. </body>
  167. </html>
  168. """,
  169. ## history.svg FIXME
  170. 'history.svg': """""",
  171. })
  172. ])
  173. tomletrans = str.maketrans({
  174. 0: '\\u0000', 1: '\\u0001', 2: '\\u0002', 3: '\\u0003', 4: '\\u0004',
  175. 5: '\\u0005', 6: '\\u0006', 7: '\\u0007', 8: '\\b', 9: '\\t', 10: '\\n',
  176. 11: '\\u000B', 12: '\\f', 13: '\\r', 14: '\\u000E', 15: '\\u000F',
  177. 16: '\\u0010', 17: '\\u0011', 18: '\\u0012', 19: '\\u0013', 20: '\\u0014',
  178. 21: '\\u0015', 22: '\\u0016', 23: '\\u0017', 24: '\\u0018', 25: '\\u0019',
  179. 26: '\\u001A', 27: '\\u001B', 28: '\\u001C', 29: '\\u001D', 30: '\\u001E',
  180. 31: '\\u001F', '"': '\\"', '\\': '\\\\'
  181. })
  182. def tomlescape(value):
  183. return value.translate(tomletrans)
  184. def get_env():
  185. env = jinja2.Environment(loader=get_template_loader(), autoescape=False)
  186. env.filters['tomlescape'] = tomlescape
  187. env.filters['tomle'] = env.filters['tomlescape']
  188. return env
  189. @click.group()
  190. def ganarchy():
  191. pass
  192. @ganarchy.command()
  193. def initdb():
  194. """Initializes the ganarchy database."""
  195. os.makedirs(data_home, exist_ok=True)
  196. conn = sqlite3.connect(data_home + "/ganarchy.db")
  197. c = conn.cursor()
  198. c.execute('''CREATE TABLE "repo_history" ("entry" INTEGER PRIMARY KEY ASC AUTOINCREMENT, "url" TEXT, "count" INTEGER, "head_commit" TEXT, "branch" TEXT, "project" TEXT)''')
  199. c.execute('''CREATE INDEX "repo_history_url_branch_project" ON "repo_history" ("url", "branch", "project")''')
  200. conn.commit()
  201. conn.close()
  202. def migrations():
  203. @ganarchy.group()
  204. def migrations():
  205. """Modifies the DB to work with a newer/older version.
  206. WARNING: THIS COMMAND CAN BE EXTREMELY DESTRUCTIVE!"""
  207. @migrations.command()
  208. @click.argument('migration')
  209. def apply(migration):
  210. """Applies the migration with the given name."""
  211. conn = sqlite3.connect(data_home + "/ganarchy.db")
  212. c = conn.cursor()
  213. click.echo(MIGRATIONS[migration][0])
  214. for migration in MIGRATIONS[migration][0]:
  215. c.execute(migration)
  216. conn.commit()
  217. conn.close()
  218. @click.argument('migration')
  219. @migrations.command()
  220. def revert(migration):
  221. """Reverts the migration with the given name."""
  222. conn = sqlite3.connect(data_home + "/ganarchy.db")
  223. c = conn.cursor()
  224. click.echo(MIGRATIONS[migration][1])
  225. for migration in MIGRATIONS[migration][1]:
  226. c.execute(migration)
  227. conn.commit()
  228. conn.close()
  229. @click.argument('migration', required=False)
  230. @migrations.command()
  231. def info(migration):
  232. """Shows information about the migration with the given name."""
  233. if not migration:
  234. # TODO could be improved
  235. click.echo(MIGRATIONS.keys())
  236. else:
  237. click.echo(MIGRATIONS[migration][2])
  238. migrations()
  239. class GitError(LookupError):
  240. """Raised when a git operation fails, generally due to a missing commit or branch, or network connection issues."""
  241. pass
  242. class Git:
  243. def __init__(self, path):
  244. self.path = path
  245. self.base = ("git", "-C", path)
  246. def get_hash(self, target):
  247. try:
  248. return subprocess.check_output(self.base + ("show", target, "-s", "--format=format:%H", "--"), stderr=subprocess.DEVNULL).decode("utf-8")
  249. except subprocess.CalledProcessError as e:
  250. raise GitError from e
  251. def get_commit_message(self, target):
  252. try:
  253. return subprocess.check_output(self.base + ("show", target, "-s", "--format=format:%B", "--"), stderr=subprocess.DEVNULL).decode("utf-8", "replace")
  254. except subprocess.CalledProcessError as e:
  255. raise GitError from e
  256. # Currently we only use one git repo, at cache_home
  257. GIT = Git(cache_home)
  258. class Repo:
  259. def __init__(self, dbconn, project_commit, url, branch, head_commit, list_metadata=False):
  260. self.url = url
  261. self.branch = branch
  262. self.project_commit = project_commit
  263. self.erroring = False
  264. if not branch:
  265. self.branchname = "gan" + hashlib.sha256(url.encode("utf-8")).hexdigest()
  266. self.head = "HEAD"
  267. else:
  268. self.branchname = "gan" + hmac.new(branch.encode("utf-8"), url.encode("utf-8"), "sha256").hexdigest()
  269. self.head = "refs/heads/" + branch
  270. if head_commit:
  271. self.hash = head_commit
  272. else:
  273. try: # FIXME should we even do this?
  274. self.hash = GIT.get_hash(self.branchname)
  275. except GitError:
  276. self.erroring = True
  277. self.hash = None
  278. self.message = None
  279. if list_metadata:
  280. try:
  281. self.update_metadata()
  282. except GitError:
  283. self.erroring = True
  284. pass
  285. def update_metadata(self):
  286. self.message = GIT.get_commit_message(self.branchname)
  287. def update(self):
  288. """
  289. Updates the git repo, returning new metadata.
  290. """
  291. try:
  292. subprocess.check_output(["git", "-C", cache_home, "fetch", "-q", self.url, "+" + self.head + ":" + self.branchname], stderr=subprocess.STDOUT)
  293. except subprocess.CalledProcessError as e:
  294. # This may error for various reasons, but some are important: dead links, etc
  295. click.echo(e.output, err=True)
  296. self.erroring = True
  297. return None
  298. pre_hash = self.hash
  299. try:
  300. post_hash = GIT.get_hash(self.branchname)
  301. except GitError as e:
  302. # This should never happen, but maybe there's some edge cases?
  303. # TODO check
  304. self.erroring = True
  305. return None
  306. self.hash = post_hash
  307. if not pre_hash:
  308. pre_hash = post_hash
  309. try:
  310. count = int(subprocess.check_output(["git", "-C", cache_home, "rev-list", "--count", pre_hash + ".." + post_hash, "--"]).decode("utf-8").strip())
  311. except subprocess.CalledProcessError:
  312. count = 0 # force-pushed
  313. try:
  314. subprocess.check_call(["git", "-C", cache_home, "merge-base", "--is-ancestor", self.project_commit, self.branchname], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  315. self.update_metadata()
  316. return count
  317. except (subprocess.CalledProcessError, GitError) as e:
  318. click.echo(e, err=True)
  319. self.erroring = True
  320. return None
  321. class Project:
  322. def __init__(self, dbconn, project_commit, list_repos=False):
  323. self.commit = project_commit
  324. self.refresh_metadata()
  325. self.repos = None
  326. if list_repos:
  327. self.list_repos(dbconn)
  328. def list_repos(self, dbconn):
  329. repos = []
  330. with dbconn:
  331. for (e, url, branch, head_commit) in dbconn.execute('''SELECT "max"("e"), "url", "branch", "head_commit" FROM (SELECT "max"("T1"."entry") "e", "T1"."url", "T1"."branch", "T1"."head_commit" FROM "repo_history" "T1"
  332. WHERE (SELECT "active" FROM "repos" "T2" WHERE "url" = "T1"."url" AND "branch" IS "T1"."branch" AND "project" IS ?1)
  333. GROUP BY "T1"."url", "T1"."branch"
  334. UNION
  335. SELECT null, "T3"."url", "T3"."branch", null FROM "repos" "T3" WHERE "active" AND "project" IS ?1)
  336. GROUP BY "url" ORDER BY "e"''', (self.commit,)):
  337. repos.append(Repo(dbconn, self.commit, url, branch, head_commit))
  338. self.repos = repos
  339. def refresh_metadata(self):
  340. try:
  341. project = GIT.get_commit_message(self.commit)
  342. project_title, project_desc = (lambda x: x.groups() if x is not None else ('', None))(re.fullmatch('^\\[Project\\]\s+(.+?)(?:\n\n(.+))?$', project, flags=re.ASCII|re.DOTALL|re.IGNORECASE))
  343. if not project_title.strip(): # FIXME
  344. project_title, project_desc = ("Error parsing project commit",)*2
  345. # if project_desc: # FIXME
  346. # project_desc = project_desc.strip()
  347. self.commit_body = project
  348. self.title = project_title
  349. self.description = project_desc
  350. except GitError:
  351. self.commit_body = None
  352. self.title = None
  353. self.description = None
  354. def update(self):
  355. # TODO? check if working correctly
  356. results = [(repo, repo.update()) for repo in self.repos]
  357. self.refresh_metadata()
  358. return results
  359. class GAnarchy:
  360. def __init__(self, dbconn, config, list_projects=False, list_repos=False):
  361. base_url = config.base_url
  362. title = config.title
  363. if not base_url:
  364. # FIXME use a more appropriate error type
  365. raise ValueError
  366. if not title:
  367. title = "GAnarchy on " + urlparse(base_url).hostname
  368. self.title = title
  369. self.base_url = base_url
  370. # load config onto DB
  371. c = dbconn.cursor()
  372. c.execute('''CREATE TEMPORARY TABLE "repos" ("url" TEXT PRIMARY KEY, "active" INT, "branch" TEXT, "project" TEXT)''')
  373. c.execute('''CREATE UNIQUE INDEX "temp"."repos_url_branch_project" ON "repos" ("url", "branch", "project")''')
  374. c.execute('''CREATE INDEX "temp"."repos_project" ON "repos" ("project")''')
  375. c.execute('''CREATE INDEX "temp"."repos_active" ON "repos" ("active")''')
  376. for (project_commit, repos) in config.projects.items():
  377. for (repo_url, branches) in repos.items():
  378. for (branchname, options) in branches.items():
  379. if options['active']: # no need to insert inactive repos since they get ignored anyway
  380. c.execute('''INSERT INTO "repos" VALUES (?, ?, ?, ?)''', (repo_url, 1, branchname, project_commit))
  381. dbconn.commit()
  382. if list_projects:
  383. projects = []
  384. with dbconn:
  385. for (project,) in dbconn.execute('''SELECT DISTINCT "project" FROM "repos" '''): # FIXME? *maybe* sort by activity in the future
  386. projects.append(Project(dbconn, project, list_repos=list_repos))
  387. projects.sort(key=lambda project: project.title) # sort projects by title
  388. self.projects = projects
  389. else:
  390. self.projects = None
  391. class Config:
  392. def __init__(self, toml_file, base=None, remove=True):
  393. self.projects = defaultdict(lambda: defaultdict(lambda: defaultdict(lambda: defaultdict(dict))))
  394. config_data = qtoml.load(toml_file)
  395. self.title = config_data.get('title', '')
  396. self.base_url = config_data.get('base_url', '')
  397. # TODO blocked domains (but only read them from config_data if remove is True)
  398. self.blocked_domains = []
  399. self.blocked_domain_suffixes = []
  400. self.blocked_domains.sort()
  401. self.blocked_domain_suffixes.sort(key=lambda x: x[::-1])
  402. # FIXME remove duplicates and process invalid entries
  403. self.blocked_domains = tuple(self.blocked_domains)
  404. self.blocked_domain_suffixes = tuple(self.blocked_domain_suffixes) # MUST be tuple
  405. # TODO re.compile("(^" + "|^".join(map(re.escape, domains)) + "|" + "|".join(map(re.escape, suffixes) + ")$")
  406. if base:
  407. # FIXME is remove=remove the right thing to do?
  408. self._update_projects(base.projects, remove=remove, sanitize=False) # already sanitized
  409. projects = config_data.get('projects', {})
  410. self._update_projects(projects, remove=remove)
  411. def _update_projects(self, projects, remove, sanitize=True):
  412. for (project_commit, repos) in projects.items():
  413. if sanitize and not isinstance(repos, dict):
  414. # TODO emit warnings?
  415. continue
  416. if sanitize and not re.fullmatch("[0-9a-fA-F]{40}|[0-9a-fA-F]{64}", project_commit): # future-proofing: sha256 support
  417. # TODO emit warnings?
  418. continue
  419. project = self.projects[project_commit]
  420. for (repo_url, branches) in repos.items():
  421. if sanitize and not isinstance(branches, dict):
  422. # TODO emit warnings?
  423. continue
  424. try:
  425. u = urlparse(repo_url)
  426. if not u:
  427. raise ValueError
  428. getattr(u, 'port') # raises ValueError if port is invalid
  429. if u.scheme in ('file', ''):
  430. raise ValueError
  431. if (u.hostname in self.blocked_domains) or (u.hostname.endswith(self.blocked_domain_suffixes)):
  432. raise ValueError
  433. except ValueError:
  434. if sanitize:
  435. # TODO emit warnings?
  436. continue
  437. else:
  438. raise
  439. repo = project[repo_url]
  440. for (branchname, options) in branches.items():
  441. if sanitize and not isinstance(options, dict):
  442. # TODO emit warnings?
  443. continue
  444. if branchname == "HEAD":
  445. if sanitize:
  446. # feels weird, but generally makes things easier
  447. # DO NOT emit warnings here. this is deliberate.
  448. branchname = None
  449. else:
  450. raise ValueError
  451. branch = repo[branchname]
  452. active = options.get('active', False)
  453. if active not in (True, False):
  454. if sanitize:
  455. # TODO emit warnings?
  456. continue
  457. else:
  458. raise ValueError
  459. ## | remove | branch.active | options.active | result |
  460. ## | x | false | false | false |
  461. ## | x | false | true | true |
  462. ## | x | true | true | true |
  463. ## | false | true | false | true |
  464. ## | true | true | false | false |
  465. branch['active'] = branch.get('active', False) or active
  466. if remove and not active:
  467. branch['active'] = False
  468. @ganarchy.command()
  469. @click.option('--skip-errors/--no-skip-errors', default=False)
  470. @click.argument('files', type=click.File('r', encoding='utf-8'), nargs=-1)
  471. def merge_configs(skip_errors, files):
  472. """Merges config files."""
  473. config = None
  474. for f in files:
  475. try:
  476. f.reconfigure(newline='')
  477. config = Config(f, config, remove=False)
  478. except (UnicodeDecodeError, qtoml.decoder.TOMLDecodeError):
  479. if not skip_errors:
  480. raise
  481. if config:
  482. env = get_env()
  483. template = env.get_template('index.toml')
  484. click.echo(template.render(config=config))
  485. @ganarchy.command()
  486. @click.argument('project', required=False)
  487. def cron_target(project):
  488. """Runs ganarchy as a cron target."""
  489. conf = None
  490. # reverse order is intentional
  491. for d in reversed(config_dirs):
  492. try:
  493. conf = Config(open(d + "/config.toml", 'r', encoding='utf-8', newline=''), conf)
  494. except (OSError, UnicodeDecodeError, qtoml.decoder.TOMLDecodeError):
  495. pass
  496. with open(config_home + "/config.toml", 'r', encoding='utf-8', newline='') as f:
  497. conf = Config(f, conf)
  498. env = get_env()
  499. if project == "config":
  500. # render the config
  501. # doesn't have access to a GAnarchy object. this is deliberate.
  502. template = env.get_template('index.toml')
  503. click.echo(template.render(config = conf))
  504. return
  505. if project == "project-list":
  506. # could be done with a template but eh w/e, this is probably better
  507. for project in conf.projects.keys():
  508. click.echo(project)
  509. return
  510. # make sure the cache dir exists
  511. os.makedirs(cache_home, exist_ok=True)
  512. # make sure it is a git repo
  513. subprocess.call(["git", "-C", cache_home, "init", "-q"])
  514. conn = sqlite3.connect(data_home + "/ganarchy.db")
  515. instance = GAnarchy(conn, conf, list_projects=project in ["index", "config"])
  516. if project == "index":
  517. # render the index
  518. template = env.get_template('index.html')
  519. click.echo(template.render(ganarchy = instance))
  520. return
  521. if not instance.base_url or not project:
  522. click.echo("No base URL or project commit specified", err=True)
  523. return
  524. entries = []
  525. generate_html = []
  526. c = conn.cursor()
  527. p = Project(conn, project, list_repos=True)
  528. results = p.update()
  529. for (repo, count) in results:
  530. if count is not None:
  531. entries.append((repo.url, count, repo.hash, repo.branch, project))
  532. generate_html.append((repo.url, repo.message, count, repo.branch))
  533. # sort stuff twice because reasons
  534. entries.sort(key=lambda x: x[1], reverse=True)
  535. generate_html.sort(key=lambda x: x[2], reverse=True)
  536. c.executemany('''INSERT INTO "repo_history" ("url", "count", "head_commit", "branch", "project") VALUES (?, ?, ?, ?, ?)''', entries)
  537. conn.commit()
  538. html_entries = []
  539. for (url, msg, count, branch) in generate_html:
  540. history = c.execute('''SELECT "count" FROM "repo_history" WHERE "url" = ? AND "branch" IS ? AND "project" IS ? ORDER BY "entry" ASC''', (url, branch, project)).fetchall()
  541. # TODO process history into SVG
  542. html_entries.append((url, msg, "", branch))
  543. template = env.get_template('project.html')
  544. click.echo(template.render(project_title = p.title,
  545. project_desc = p.description,
  546. project_body = p.commit_body,
  547. project_commit = p.commit,
  548. repos = html_entries,
  549. base_url = instance.base_url,
  550. # I don't think this thing supports deprecating the above?
  551. project = p,
  552. ganarchy = instance))
  553. if __name__ == "__main__":
  554. ganarchy()