A gitsrht/alembic/versions/1152333caa0b_add_source_repo_id_to_repository.py => gitsrht/alembic/versions/1152333caa0b_add_source_repo_id_to_repository.py +25 -0
@@ 0,0 1,25 @@
+"""Add source_repo_id to Repository
+
+Revision ID: 1152333caa0b
+Revises: ddca72f1b7e2
+Create Date: 2019-10-14 14:22:16.032157
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '1152333caa0b'
+down_revision = 'ddca72f1b7e2'
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+ op.add_column('repository', sa.Column('source_repo_id',
+ sa.Integer, sa.ForeignKey('repository.id')))
+ op.add_column('repository', sa.Column('upstream_uri', sa.Unicode))
+
+
+def downgrade():
+ op.drop_column('repository', 'source_repo_id')
+ op.drop_column('repository', 'upstream_uri')
M gitsrht/app.py => gitsrht/app.py +2 -0
@@ 23,11 23,13 @@ class GitApp(ScmSrhtFlask):
repo_api=GitRepoApi(), oauth_service=oauth_service)
from gitsrht.blueprints.api import data
+ from gitsrht.blueprints.email import mail
from gitsrht.blueprints.internal import internal
from gitsrht.blueprints.repo import repo
from gitsrht.blueprints.stats import stats
self.register_blueprint(data)
+ self.register_blueprint(mail)
self.register_blueprint(internal)
self.register_blueprint(repo)
self.register_blueprint(stats)
M gitsrht/blueprints/api.py => gitsrht/blueprints/api.py +2 -1
@@ 3,8 3,9 @@ import json
import pygit2
from flask import Blueprint, current_app, request, send_file, abort
from gitsrht.annotations import validate_annotation
-from gitsrht.blueprints.repo import lookup_ref, get_log, collect_refs
+from gitsrht.blueprints.repo import lookup_ref, collect_refs
from gitsrht.git import Repository as GitRepository, commit_time, annotate_tree
+from gitsrht.git import get_log
from gitsrht.webhooks import RepoWebhook
from io import BytesIO
from scmsrht.access import UserAccess
A gitsrht/blueprints/email.py => gitsrht/blueprints/email.py +303 -0
@@ 0,0 1,303 @@
+import email
+import mailbox
+import pygit2
+import re
+import smtplib
+import subprocess
+import sys
+from email.policy import SMTPUTF8
+from email.utils import make_msgid, parseaddr
+from flask import Blueprint, render_template, abort, request, url_for, session
+from flask import redirect
+from gitsrht.git import Repository as GitRepository, commit_time, diffstat
+from gitsrht.git import get_log
+from scmsrht.access import get_repo_or_redir
+from srht.config import cfg, cfgi, cfgb
+from srht.flask import loginrequired, current_user
+from srht.validation import Validation
+from tempfile import NamedTemporaryFile
+from textwrap import TextWrapper
+
+mail = Blueprint('mail', __name__)
+
+smtp_host = cfg("mail", "smtp-host", default=None)
+smtp_port = cfgi("mail", "smtp-port", default=None)
+smtp_user = cfg("mail", "smtp-user", default=None)
+smtp_password = cfg("mail", "smtp-password", default=None)
+smtp_from = cfg("mail", "smtp-from", default=None)
+
+@mail.route("/<owner>/<repo>/send-email")
+@loginrequired
+def send_email_start(owner, repo):
+ owner, repo = get_repo_or_redir(owner, repo)
+ with GitRepository(repo.path) as git_repo:
+ ncommits = int(request.args.get("commits", default=8))
+ if ncommits > 32:
+ ncommits = 32
+ selected_branch = request.args.get("branch", default=None)
+
+ branches = [(
+ branch,
+ git_repo.branches[branch],
+ git_repo.get(git_repo.branches[branch].target)
+ ) for branch in git_repo.branches.local]
+ default_branch = git_repo.default_branch().name
+ branches = sorted(branches,
+ key=lambda b: (b[0] == selected_branch, commit_time(b[2])),
+ reverse=True)
+
+ commits = dict()
+ for branch in branches[:2]:
+ commits[branch[0]] = get_log(git_repo,
+ branch[2], commits_per_page=ncommits)
+
+ return render_template("send-email.html",
+ view="send-email", owner=owner, repo=repo,
+ selected_branch=selected_branch, branches=branches,
+ commits=commits)
+
+@mail.route("/<owner>/<repo>/send-email/end", methods=["POST"])
+@loginrequired
+def send_email_end(owner, repo):
+ owner, repo = get_repo_or_redir(owner, repo)
+ with GitRepository(repo.path) as git_repo:
+ valid = Validation(request)
+ branch = valid.require("branch")
+ commit = valid.require(f"commit-{branch}")
+
+ branch = git_repo.branches[branch]
+ tip = git_repo.get(branch.target)
+ start = git_repo.get(commit)
+
+ log = get_log(git_repo, tip, until=start)
+ diffs = list()
+ for commit in log:
+ try:
+ parent = git_repo.revparse_single(commit.oid.hex + "^")
+ diff = git_repo.diff(parent, commit)
+ except KeyError:
+ parent = None
+ diff = commit.tree.diff_to_tree(swap=True)
+ diff.find_similar(pygit2.GIT_DIFF_FIND_RENAMES)
+ diffs.append(diff)
+
+ return render_template("send-email-end.html",
+ view="send-email", owner=owner, repo=repo,
+ commits=log, start=start, diffs=diffs,
+ diffstat=diffstat)
+
+commentary_re = re.compile(r"""
+---\n
+(?P<context>
+ (\ .*\ +\|\ +\d+\ [-+]+\n)+
+ \ \d+\ files?\ changed,.*\n
+ \n
+ diff\ --git
+)
+""", re.MULTILINE | re.VERBOSE)
+
+def prepare_patchset(repo, git_repo, cover_letter=None, extra_headers=False,
+ to=None, cc=None):
+ with NamedTemporaryFile() as ntf:
+ wrapper = TextWrapper(
+ expand_tabs=False,
+ replace_whitespace=False,
+ width=72,
+ drop_whitespace=True,
+ break_long_words=False)
+
+ valid = Validation(request)
+ start_commit = valid.require("start_commit")
+ end_commit = valid.require("end_commit")
+ cover_letter_subject = valid.optional("cover_letter_subject")
+ if cover_letter is None:
+ cover_letter = valid.optional("cover_letter")
+ if not valid.ok:
+ return None
+
+ outgoing_domain = cfg("git.sr.ht", "outgoing-domain")
+ args = [
+ "git",
+ "--git-dir", repo.path,
+ "-c", f"user.name=~{current_user.username}",
+ "-c", f"user.email={current_user.username}@{outgoing_domain}",
+ "format-patch",
+ f"--from=~{current_user.username} <{current_user.username}@{outgoing_domain}>",
+ f"--subject-prefix=PATCH {repo.name}",
+ "--stdout",
+ ]
+ if cover_letter:
+ args += ["--cover-letter"]
+ args += [f"{start_commit}^..{end_commit}"]
+ print(args)
+ p = subprocess.run(args, timeout=30,
+ stdout=subprocess.PIPE, stderr=sys.stderr)
+ if p.returncode != 0:
+ abort(400) # TODO: Something more useful, I suppose.
+
+ ntf.write(p.stdout)
+ ntf.flush()
+
+ policy = SMTPUTF8.clone(max_line_length=998)
+ factory = lambda f: email.message_from_bytes(f.read(), policy=policy)
+ mbox = mailbox.mbox(ntf.name)
+ emails = list(mbox)
+
+ if cover_letter:
+ subject = emails[0]["Subject"]
+ del emails[0]["Subject"]
+ emails[0]["Subject"] = (subject
+ .replace("*** SUBJECT HERE ***", cover_letter_subject))
+ body = emails[0].get_payload(decode=True).decode()
+ cover_letter = "\n".join(wrapper.wrap(cover_letter))
+ body = body.replace("*** BLURB HERE ***", cover_letter)
+ emails[0].set_payload(body)
+
+ for i, email in enumerate(emails[(1 if cover_letter else 0):]):
+ commentary = valid.optional(f"commentary-{i}")
+ if not commentary:
+ continue
+ commentary = "\n".join(wrapper.wrap(commentary))
+ body = email.get_payload(decode=True).decode()
+ body = commentary_re.sub(r"---\n" + commentary.replace(
+ "\\", r"\\") + r"\n\n\g<context>", body, count=1)
+ email.set_payload(body)
+
+ if extra_headers:
+ msgid = make_msgid().split("@")
+ for i, email in enumerate(emails):
+ email["Message-ID"] = f"{msgid[0]}-{i}@{msgid[1]}"
+ email["X-Mailer"] = "git.sr.ht"
+ if i != 0:
+ email["In-Reply-To"] = f"{msgid[0]}-{0}@{msgid[1]}"
+ if to:
+ email["To"] = to
+ if cc:
+ email["Cc"] = cc
+
+ return emails
+
+@mail.route("/<owner>/<repo>/send-email/review", methods=["POST"])
+@loginrequired
+def send_email_review(owner, repo):
+ owner, repo = get_repo_or_redir(owner, repo)
+ with GitRepository(repo.path) as git_repo:
+ valid = Validation(request)
+ start_commit = valid.require("start_commit")
+ end_commit = valid.require("end_commit")
+ cover_letter = valid.optional("cover_letter")
+ cover_letter_subject = valid.optional("cover_letter_subject")
+ if cover_letter and not cover_letter_subject:
+ valid.error("Cover letter subject is required.",
+ field="cover_letter_subject")
+ if cover_letter_subject and not cover_letter:
+ valid.error("Cover letter body is required.", field="cover_letter")
+
+ default_branch = git_repo.default_branch()
+ tip = git_repo.get(default_branch.target)
+ readme = None
+ if "README.md" in tip.tree:
+ readme = "README.md"
+ elif "README" in tip.tree:
+ readme = "README"
+
+ emails = prepare_patchset(repo, git_repo)
+ if not emails or not valid.ok:
+ tip = git_repo.get(end_commit)
+ start = git_repo.get(start_commit)
+
+ log = get_log(git_repo, tip, until=start)
+ diffs = list()
+ for commit in log:
+ try:
+ parent = git_repo.revparse_single(commit.oid.hex + "^")
+ diff = git_repo.diff(parent, commit)
+ except KeyError:
+ parent = None
+ diff = commit.tree.diff_to_tree(swap=True)
+ diff.find_similar(pygit2.GIT_DIFF_FIND_RENAMES)
+ diffs.append(diff)
+
+ return render_template("send-email-end.html",
+ view="send-email", owner=owner, repo=repo,
+ commits=log, start=start, diffs=diffs,
+ diffstat=diffstat, **valid.kwargs)
+
+ session["cover_letter"] = cover_letter
+ return render_template("send-email-review.html",
+ view="send-email", owner=owner, repo=repo,
+ readme=readme, emails=emails,
+ start=git_repo.get(start_commit),
+ end=git_repo.get(end_commit),
+ cover_letter=bool(cover_letter),
+ cover_letter_subject=cover_letter_subject)
+
+@mail.route("/<owner>/<repo>/send-email/send", methods=["POST"])
+@loginrequired
+def send_email_send(owner, repo):
+ owner, repo = get_repo_or_redir(owner, repo)
+ with GitRepository(repo.path) as git_repo:
+ valid = Validation(request)
+ start_commit = valid.require("start_commit")
+ end_commit = valid.require("end_commit")
+ cover_letter_subject = valid.optional("cover_letter_subject")
+
+ to = valid.require("patchset_to", friendly_name="To")
+ cc = valid.optional("patchset_cc")
+ recipients = list()
+
+ if to:
+ to_recipients = [parseaddr(r)[1] for r in to.split(",")]
+ valid.expect('' not in to_recipients,
+ "Invalid recipient.", field="patchset_to")
+ recipients += to_recipients
+ if cc:
+ cc_recipients = [parseaddr(r)[1] for r in cc.split(",")]
+ valid.expect('' not in cc_recipients,
+ "Invalid recipient.", field="patchset_cc")
+ recipients += cc_recipients
+
+ if not valid.ok:
+ cover_letter = session.get("cover_letter")
+ emails = prepare_patchset(repo, git_repo, cover_letter=cover_letter)
+
+ default_branch = git_repo.default_branch()
+ tip = git_repo.get(default_branch.target)
+ readme = None
+ if "README.md" in tip.tree:
+ readme = "README.md"
+ elif "README" in tip.tree:
+ readme = "README"
+
+ return render_template("send-email-review.html",
+ view="send-email", owner=owner, repo=repo,
+ readme=readme, emails=emails,
+ start=git_repo.get(start_commit),
+ end=git_repo.get(end_commit),
+ cover_letter=bool(cover_letter),
+ **valid.kwargs)
+
+ cover_letter = session.pop("cover_letter", None)
+ emails = prepare_patchset(repo, git_repo,
+ cover_letter=cover_letter, extra_headers=True,
+ to=to, cc=cc)
+ if not emails:
+ abort(400) # Should work by this point
+
+ # TODO: Send emails asyncronously
+ smtp = smtplib.SMTP(smtp_host, smtp_port)
+ smtp.ehlo()
+ if smtp_user and smtp_password:
+ smtp.starttls()
+ smtp.login(smtp_user, smtp_password)
+ print("Sending to receipients", recipients)
+ for email in emails:
+ smtp.sendmail(smtp_user, recipients,
+ email.as_bytes(unixfrom=False))
+ smtp.quit()
+
+ # TODO: If we're connected to a lists.sr.ht address, link to their URL
+ # in the archives.
+ session["message"] = "Your patchset has been sent."
+ return redirect(url_for('repo.summary',
+ owner=repo.owner, repo=repo.name))
M gitsrht/blueprints/repo.py => gitsrht/blueprints/repo.py +8 -14
@@ 6,18 6,18 @@ import pygments
import subprocess
import sys
from datetime import timedelta
-from jinja2 import Markup
from flask import Blueprint, render_template, abort, send_file, request
-from flask import Response, url_for
+from flask import Response, url_for, session
from gitsrht.annotations import AnnotatedFormatter
from gitsrht.editorconfig import EditorConfig
from gitsrht.git import Repository as GitRepository, commit_time, annotate_tree
-from gitsrht.git import diffstat
+from gitsrht.git import diffstat, get_log
from gitsrht.rss import generate_feed
from io import BytesIO
+from jinja2 import Markup
from pygments import highlight
-from pygments.lexers import guess_lexer, guess_lexer_for_filename, TextLexer
from pygments.formatters import HtmlFormatter
+from pygments.lexers import guess_lexer, guess_lexer_for_filename, TextLexer
from scmsrht.access import get_repo, get_repo_or_redir
from scmsrht.formatting import get_formatted_readme, get_highlighted_file
from scmsrht.redis import redis
@@ 97,10 97,13 @@ def summary(owner, repo):
if isinstance(tag[1], pygit2.Tag) or isinstance(tag[1], pygit2.Commit)]
tags = sorted(tags, key=lambda c: commit_time(c[1]), reverse=True)
latest_tag = tags[0] if len(tags) else None
+
+ message = session.pop("message", None)
return render_template("summary.html", view="summary",
owner=owner, repo=repo, readme=readme, commits=commits,
latest_tag=latest_tag, default_branch=default_branch,
- is_annotated=lambda t: isinstance(t, pygit2.Tag))
+ is_annotated=lambda t: isinstance(t, pygit2.Tag),
+ message=message)
def lookup_ref(git_repo, ref, path):
ref = ref or git_repo.default_branch().name[len("refs/heads/"):]
@@ 278,15 281,6 @@ def collect_refs(git_repo):
refs[_ref.commit.id.hex].append(_ref)
return refs
-def get_log(git_repo, commit, commits_per_page=20):
- commits = list()
- for commit in git_repo.walk(commit.id, pygit2.GIT_SORT_TIME):
- commits.append(commit)
- if len(commits) >= commits_per_page + 1:
- break
-
- return commits
-
@repo.route("/<owner>/<repo>/log", defaults={"ref": None, "path": ""})
@repo.route("/<owner>/<repo>/log/<path:ref>", defaults={"path": ""})
@repo.route("/<owner>/<repo>/log/<ref>/<path:path>")
M gitsrht/git.py => gitsrht/git.py +16 -6
@@ 26,6 26,16 @@ def commit_time(commit):
def _get_ref(repo, ref):
return repo._get(ref)
+def get_log(git_repo, commit, commits_per_page=20, until=None):
+ commits = list()
+ for commit in git_repo.walk(commit.id, pygit2.GIT_SORT_TIME):
+ commits.append(commit)
+ if until is not None and commit == until:
+ break
+ elif len(commits) >= commits_per_page + 1:
+ break
+ return commits
+
class Repository(GitRepository):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ 140,12 150,12 @@ def annotate_tree(repo, tree, commit):
return [entry.fetch_blob() for entry in tree.values()]
-def _diffstat_name(delta):
+def _diffstat_name(delta, anchor):
if delta.status == pygit2.GIT_DELTA_DELETED:
return Markup(escape(delta.old_file.path))
if delta.old_file.path == delta.new_file.path:
return Markup(
- f"<a href='#{escape(delta.old_file.path)}'>" +
+ f"<a href='#{escape(anchor)}{escape(delta.old_file.path)}'>" +
f"{escape(delta.old_file.path)}" +
f"</a>")
# Based on git/diff.c
@@ 164,8 174,8 @@ def _diffstat_name(delta):
f"}}")
return f"{delta.old_file.path} => {delta.new_file.path}"
-def _diffstat_line(delta, patch):
- name = _diffstat_name(delta)
+def _diffstat_line(delta, patch, anchor):
+ name = _diffstat_name(delta, anchor)
change = ""
if delta.status not in [
pygit2.GIT_DELTA_ADDED,
@@ 179,12 189,12 @@ def _diffstat_line(delta, patch):
f"{filemode(delta.new_file.mode)}</span>")
return Markup(f"{delta.status_char()} {name}{change}\n")
-def diffstat(diff):
+def diffstat(diff, anchor=""):
stat = Markup(f"""{diff.stats.files_changed} files changed, <strong
class="text-success">{diff.stats.insertions
}</strong> insertions(+), <strong
class="text-danger">{diff.stats.deletions
}</strong> deletions(-)\n\n""")
for delta, patch in zip(diff.deltas, diff):
- stat += _diffstat_line(delta, patch)
+ stat += _diffstat_line(delta, patch, anchor)
return stat
M gitsrht/repos.py => gitsrht/repos.py +15 -0
@@ 34,3 34,18 @@ class GitRepoApi(SimpleRepoApi):
RepoWebhook.Subscription.query.filter(
RepoWebhook.Subscription.repo_id == repo.id).delete()
super().do_delete_repo(repo)
+
+ def do_clone_repo(self, source, repo):
+ subprocess.run(["mkdir", "-p", repo.path], check=True,
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ subprocess.run(["git", "clone", "--bare", source, repo.path])
+ subprocess.run(["git", "config", "srht.repo-id", str(repo.id)], check=True,
+ cwd=repo.path, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ subprocess.run(["ln", "-s",
+ post_update,
+ os.path.join(repo.path, "hooks", "update")
+ ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ subprocess.run(["ln", "-s",
+ post_update,
+ os.path.join(repo.path, "hooks", "post-update")
+ ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
M gitsrht/templates/commit.html => gitsrht/templates/commit.html +1 -67
@@ 40,73 40,7 @@
<pre>{{diffstat(diff)}}</pre>
</div>
<div style="margin-bottom: 2rem"></div>
- {# God, working with <pre> tags is such a fucking mess #}
- {% for patch in diff %}
- <pre style="margin-bottom: 0;"
- >{#
- #}{{patch.delta.status_char()}} {% if parent %}<a
- href="{{url_for("repo.tree",
- owner=repo.owner.canonical_name,
- repo=repo.name,
- ref=parent.id.hex,
- path=patch.delta.old_file.path)}}"
- id="{{patch.delta.old_file.path}}"
- >{{patch.delta.old_file.path}}</a>{#
- #}{% endif %} => {#
- #}<a
- href="{{url_for("repo.tree",
- owner=repo.owner.canonical_name,
- repo=repo.name,
- ref=commit.id.hex,
- path=patch.delta.new_file.path)}}"
- id="{{patch.delta.new_file.path}}"
- >{{patch.delta.new_file.path}}</a>{#
- #} <span class="pull-right"><span class="text-success">+{{patch.line_stats[1]}}</span>{#
- #} <span class="text-danger">-{{patch.line_stats[2]}}</span></span>{%
- if patch.delta.old_file.mode != patch.delta.new_file.mode %}{#
- #}{#
- #}{% endif %}</pre>
- <div class="event diff">
- <pre>{% for hunk in patch.hunks %}
-{% set hunk_index = loop.index %}<strong
- class="text-info"
->@@ {#
-#}{% if parent %}<a
- style="text-decoration: underline"
- href="{{url_for("repo.tree",
- owner=repo.owner.canonical_name,
- repo=repo.name,
- ref=parent.id.hex,
- path=patch.delta.old_file.path)}}#L{{hunk.old_start}}"
->{{hunk.old_start}}</a>,{{hunk.old_lines}} {#
-#}{% endif %}<a
- style="text-decoration: underline"
- href="{{url_for("repo.tree",
- owner=repo.owner.canonical_name,
- repo=repo.name,
- ref=commit.id.hex,
- path=patch.delta.new_file.path)}}#L{{hunk.new_start}}"
->{{hunk.new_start}}</a>,{{hunk.new_lines}} {#
-#}@@</strong
->{% if hunk.old_start == 0 %}
-{% endif %}{% for line in hunk.lines
-%}<span class="{{({
- "+":"text-success",
- "-":"text-danger",
- }).get(line.origin) or ""}}"><a
- href="#{{patch.delta.old_file.path}}-{{hunk_index}}-{{loop.index}}"
- id="{{patch.delta.old_file.path}}-{{hunk_index}}-{{loop.index}}"
- style="color: inherit"
->{{line.origin}}</a>{%
- if loop.first and hunk.old_start != 0
-%}{{line.content.lstrip()}}{%
- else
-%} {{line.content}}{%
- endif
-%}</span>{% endfor %}
-{% endfor %}</pre>
- </div>
- {% endfor %}
+ {{utils.commit_diff(repo, commit, diff)}}
</div>
</div>
</div>
A gitsrht/templates/send-email-end.html => gitsrht/templates/send-email-end.html +134 -0
@@ 0,0 1,134 @@
+{% extends "layout.html" %}
+{% import "utils.html" as utils with context %}
+{% block title %}
+<title>Preparing patchset for {{repo.owner.canonical_name}}/{{repo.name}} - {{cfg("sr.ht", "site-name")}} git</title>
+{% endblock %}
+{% block body %}
+<div class="header-tabbed">
+ <div class="container">
+ <ul class="nav nav-tabs">
+ <h2>
+ <a
+ href="/{{ owner.canonical_name }}"
+ >{{ owner.canonical_name }}</a>/{{ repo.name }}
+ </h2>
+ <li class="nav-item">
+ <a class="nav-link" href="{{url_for("repo.summary",
+ owner=repo.owner.canonical_name, repo=repo.name)}}">
+ {{icon("caret-left")}} back
+ </a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link active" href="{{url_for("mail.send_email_start",
+ owner=repo.owner.canonical_name, repo=repo.name)}}">
+ prepare patchset
+ </a>
+ </li>
+ </ul>
+ </div>
+</div>
+<form
+ class="container prepare-patchset"
+ method="POST"
+ action="{{url_for('mail.send_email_review',
+ owner=repo.owner.canonical_name, repo=repo.name)}}"
+>
+ {{csrf_token()}}
+ <legend>Finalize the patchset</legend>
+ <small class="event-list-help">
+ You can prune too-recent commits now. You'll be able to review the final
+ patchset before it's sent in the next step.
+ </small>
+ <div class="event-list commit-list reverse">
+ <input type="hidden" name="start_commit" value="{{start.oid.hex}}" />
+ {% for c in commits %}
+ <input
+ type="radio"
+ name="end_commit"
+ id="commit-{{c.id.hex}}"
+ value="{{c.id.hex}}"
+ {% if loop.first %}checked{% endif %} />
+ <label class="event" for="commit-{{c.id.hex}}">
+ {{ utils.commit_event(repo, c, False, href="#commit-diff-" + c.oid.hex) }}
+ </label>
+ {% endfor %}
+
+ <details
+ {% if valid.error_for("cover_letter", "cover_letter_subject") %}
+ open
+ {% endif %}
+ >
+ <summary>Add a cover letter</summary>
+ <small class="text-muted">
+ The cover letter is used to describe the patchset as a whole. Add any
+ comments useful for the reviewers of this patch. It will be wrapped to
+ 72 columns.
+ </small>
+ <div class="form-group">
+ <input
+ type="text"
+ name="cover_letter_subject"
+ class="form-control {{valid.cls("cover_letter_subject")}}"
+ placeholder="Subject..."
+ value="{{cover_letter_subject or ""}}" />
+ {{valid.summary("cover_letter_subject")}}
+ </div>
+ <textarea
+ class="form-control {{valid.cls("cover_letter")}}"
+ rows="8"
+ name="cover_letter"
+ placeholder="Details..."
+ >{{cover_letter or ""}}</textarea>
+ {{valid.summary("cover_letter")}}
+ </details>
+
+ <div class="form-controls">
+ <button class="btn btn-primary">Continue {{icon("caret-right")}}</a>
+ </div>
+
+ {% for diff in diffs %}
+ {% set c = commits[loop.index-1] %}
+ <div class="commit-diff" id="commit-diff-{{c.oid.hex}}">
+ <h3>{{ trim_commit(c.message) }}</h3>
+ <div class="event commit-event">
+ {{ utils.commit_event(repo, c, full_body=True, diff=diff) }}
+ <details>
+ <summary>Add commentary</summary>
+ <small class="text-muted">
+ Add details or caveats useful for reviewing or testing this commit.
+ This won't appear in the log once the patch is applied. It will be
+ wrapped to 72 columns.
+ </small>
+ <textarea
+ class="form-control"
+ rows="4"
+ name="commentary-{{len(diffs) - loop.index}}"
+ ></textarea>
+ </details>
+ </div>
+ {{utils.commit_diff(repo, c, diff,
+ anchor=c.oid.hex + "-", target_blank=True)}}
+ </div>
+ {% endfor %}
+
+ <div class="form-controls last">
+ <button class="btn btn-primary">Continue {{icon("caret-right")}}</a>
+ </div>
+ </div>
+ <style>
+ .commit-diff {
+ display: none;
+ }
+
+ {% for c in commits %}
+ {% for d in commits[loop.index-1:] %}
+ #commit-{{c.oid.hex}}:checked ~ #commit-diff-{{d.oid.hex}}
+ {%- if not loop.last %},{% endif %}
+ {% endfor %}
+ {
+ display: block;
+ }
+ {% endfor %}
+ </style>
+</form>
+{% endblock %}
A gitsrht/templates/send-email-review.html => gitsrht/templates/send-email-review.html +111 -0
@@ 0,0 1,111 @@
+{% extends "layout.html" %}
+{% import "utils.html" as utils with context %}
+{% block title %}
+<title>Review patchset for {{repo.owner.canonical_name}}/{{repo.name}} - {{cfg("sr.ht", "site-name")}} git</title>
+{% endblock %}
+{% block body %}
+<div class="header-tabbed">
+ <div class="container">
+ <ul class="nav nav-tabs">
+ <h2>
+ <a
+ href="/{{ owner.canonical_name }}"
+ >{{ owner.canonical_name }}</a>/{{ repo.name }}
+ </h2>
+ <li class="nav-item">
+ <a class="nav-link" href="{{url_for("repo.summary",
+ owner=repo.owner.canonical_name, repo=repo.name)}}">
+ {{icon("caret-left")}} back
+ </a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link active" href="{{url_for("mail.send_email_start",
+ owner=repo.owner.canonical_name, repo=repo.name)}}">
+ prepare patchset
+ </a>
+ </li>
+ </ul>
+ </div>
+</div>
+<form
+ class="container prepare-patchset"
+ method="POST"
+ action="{{url_for('mail.send_email_send',
+ owner=repo.owner.canonical_name,
+ repo=repo.name)}}"
+>
+ <h3>Review your patchset</h3>
+ <p>
+ The following emails are going to be sent on your behalf. To whom should
+ they be sent?
+ </p>
+ {{csrf_token()}}
+ <input type="hidden" name="start_commit" value="{{start.oid.hex}}" />
+ <input type="hidden" name="end_commit" value="{{end.oid.hex}}" />
+ <input type="hidden" name="cover_letter_subject" value="{{cover_letter_subject}}" />
+ <div class="row">
+ <div class="col-md-10">
+ <div class="form-group">
+ <label for="patchset_to">To</label>
+ <input
+ type="text"
+ name="patchset_to"
+ id="patchset_to"
+ class="form-control {{valid.cls('patchset_to')}}"
+ placeholder="Joe Bloe <jbloe@example.org>, Jane Doe <jdoe@example.org>" />
+ {{valid.summary('patchset_to')}}
+ <small class="form-text text-muted">
+ This is usually a mailing list, or the project maintainer(s).
+ {% if readme %}
+ Check <a
+ href="{{url_for('repo.summary', owner=owner, repo=repo.name)}}#readme"
+ target="_blank"
+ >{{readme}}</a> for more info.
+ {% endif %}
+ </small>
+ </div>
+ <div class="form-group" style="margin-bottom: 0">
+ <label for="patchset_cc">Cc</label>
+ <input
+ type="text"
+ name="patchset_cc"
+ id="patchset_cc"
+ class="form-control {{valid.cls('patchset_cc')}}"
+ placeholder="Jane Doe <jdoe@example.org>, Joe Bloe <jbloe@example.org>" />
+ {{valid.summary('patchset_cc')}}
+ </div>
+ </div>
+ <div class="col-md-2 d-flex flex-column justify-content-end">
+ <div class="form-group" style="margin-bottom: 0">
+ <button class="btn btn-primary btn-block">
+ Send patchset {{icon('caret-right')}}
+ </button>
+ </div>
+ </div>
+ </div>
+</form>
+{# TODO: highlight the diff? #}
+<div class="container">
+ <div class="alert alert-info">
+ <p>
+ This is equivalent to the following
+ <a href="https://git-send-email.io">git send-email</a> command:
+ </p>
+ {# TODO: More concise send-email commands, e.g. use HEAD where appropriate #}
+ <pre
+ style="margin-bottom: 0;"
+ >git config format.subjectPrefix "{{repo.name}}" <span class="text-muted"># Only necessary once</span>
+git send-email {% if cover_letter %}--cover-letter {% endif %}{{start.short_id}}^..{{end.short_id}}</pre>
+ </div>
+ <div class="event-list">
+ {% for email in emails %}
+ <h3>{{email["Subject"]}}</h3>
+ <pre class="event"><span class="text-muted">
+{%- for key, value in email.items() -%}
+{{key}}: {{value}}
+{% endfor %}</span>
+{{email.get_payload(decode=True).decode()}}</pre>
+ {% endfor %}
+ </div>
+</div>
+{% endblock %}
A gitsrht/templates/send-email.html => gitsrht/templates/send-email.html +131 -0
@@ 0,0 1,131 @@
+{% extends "layout.html" %}
+{% import "utils.html" as utils with context %}
+{% block title %}
+<title>Preparing patchset for {{repo.owner.canonical_name}}/{{repo.name}} - {{cfg("sr.ht", "site-name")}} git</title>
+{% endblock %}
+{% block body %}
+<div class="header-tabbed">
+ <div class="container">
+ <ul class="nav nav-tabs">
+ <h2>
+ <a
+ href="/{{ owner.canonical_name }}"
+ >{{ owner.canonical_name }}</a>/{{ repo.name }}
+ </h2>
+ <li class="nav-item">
+ <a class="nav-link" href="{{url_for("repo.summary",
+ owner=repo.owner.canonical_name, repo=repo.name)}}">
+ {{icon("caret-left")}} back
+ </a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link active" href="{{url_for("mail.send_email_start",
+ owner=repo.owner.canonical_name, repo=repo.name)}}">
+ prepare patchset
+ </a>
+ </li>
+ </ul>
+ </div>
+</div>
+<form
+ class="container prepare-patchset"
+ method="POST"
+ action="{{url_for('mail.send_email_end',
+ owner=repo.owner.canonical_name, repo=repo.name)}}"
+>
+ {{csrf_token()}}
+ <legend>Select a branch</legend>
+
+ {% for branch in branches[:2] %}
+ <input
+ type="radio"
+ name="branch"
+ value="{{branch[0]}}"
+ id="branch-{{branch[0]}}"
+ {% if loop.first %}checked{% endif %}
+ />
+ <label for="branch-{{branch[0]}}">
+ {{branch[0]}}
+ <span class="text-muted">
+ (active {{ commit_time(branch[2]) | date }})
+ </span>
+ </label>
+ {% endfor %}
+
+ {% if any(branches[2:]) %}
+ <details>
+ <summary>More branches</summary>
+ <ul>
+ {% for branch in branches[2:] %}
+ <li>
+ {{branch[0]}}
+ <span class="text-muted">(active {{commit_time(branch[2]) | date}})</span>
+ <br />
+ <a href="?branch={{branch[0]}}">
+ Select this branch {{icon('caret-right')}}
+ </a>
+ </li>
+ {% endfor %}
+ </ul>
+ </details>
+ {% endif %}
+
+ <legend>Select the first commit</legend>
+ <small class="event-list-help">
+ Choose the earliest commit which you want to include in the patchset.
+ You'll be able to trim commits off the top in the next step.
+ </small>
+ {% for branch in branches[:2] %}
+ <div class="event-list commit-list reverse commits-{{branch[0]}}">
+ {% if commits[branch[0]][-1].parents %}
+ {% set show_commits = commits[branch[0]][:-1] %}
+ {% else %}
+ {% set show_commits = commits[branch[0]] %}
+ {% endif %}
+ {% for c in show_commits[::-1] %}
+ <input
+ type="radio"
+ name="commit-{{branch[0]}}"
+ id="commit-{{branch[0]}}-{{c.id.hex}}"
+ value="{{c.id.hex}}"
+ {% if loop.last %}checked{% endif %} />
+ <label class="event" for="commit-{{branch[0]}}-{{c.id.hex}}">
+ {{ utils.commit_event(repo, c, False, target_blank=True) }}
+ </label>
+ {% endfor %}
+ </div>
+ <div class="pull-right form-controls form-controls-{{branch[0]}}">
+ {% if commits[branch[0]][-1].parents and (len(commits[branch[0]])-1) < 32 %}
+ {# TODO: suggest request-pull for >32 commits (or less, tbh) #}
+ <a
+ class="btn btn-default"
+ {% if selected_branch %}
+ href="?commits={{(len(commits[branch[0]])-1) * 2}}&branch={{selected_branch}}"
+ {% else %}
+ href="?commits={{(len(commits[branch[0]])-1) * 2}}"
+ {% endif %}
+ >Add more commits {{icon("caret-right")}}</a>
+ {% endif %}
+ <button
+ class="btn btn-primary"
+ >Continue {{icon("caret-right")}}</a>
+ </div>
+ <div class="clearfix"></div>
+ {% endfor %}
+ <style>
+ .event-list.commit-list, .form-controls {
+ display: none;
+ }
+
+ {% for branch in branches[:2] %}
+ #branch-{{branch[0]}}:checked ~ .commits-{{branch[0]}} {
+ display: flex;
+ }
+
+ #branch-{{branch[0]}}:checked ~ .form-controls-{{branch[0]}} {
+ display: block;
+ }
+ {% endfor %}
+ </style>
+</form>
+{% endblock %}
M gitsrht/templates/summary.html => gitsrht/templates/summary.html +93 -43
@@ 12,6 12,9 @@
</div>
{% endif %}
<div class="container">
+ {% if message %}
+ <div class="alert alert-success">{{message}}</div>
+ {% endif %}
<div class="row" style="margin-bottom: 1rem">
<div class="col-md-6">
<div class="event-list" style="margin-bottom: 0.5rem">
@@ 22,54 25,101 @@
{% endfor %}
</div>
</div>
- <div class="col-md-2">
- <h3>refs</h3>
- <dl>
- {% if default_branch %}
- <dt>{{default_branch.name[len("refs/heads/"):]}}</dt>
- <dd>
- <a href="{{url_for("repo.tree",
- owner=repo.owner.canonical_name, repo=repo.name)}}"
- >browse {{icon("caret-right")}}</a>
- <a href="{{url_for("repo.log",
- owner=repo.owner.canonical_name, repo=repo.name)}}"
- >log {{icon("caret-right")}}</a>
- </dd>
- {% endif %}
- {% if latest_tag %}
- <dt>{{ latest_tag[0][len("refs/tags/"):] }}</dt>
- <dd>
- {% if is_annotated(latest_tag[1]) %}
- <a href="{{url_for("repo.ref",
- owner=repo.owner.canonical_name,
- repo=repo.name, ref=latest_tag[1].name)}}"
- >release notes {{icon("caret-right")}}</a>
+ <div class="col-md-6">
+ <div class="row">
+ <div class="col-md-4">
+ <h3>refs</h3>
+ <dl>
+ {% if default_branch %}
+ <dt>{{default_branch.name[len("refs/heads/"):]}}</dt>
+ <dd>
+ <a href="{{url_for("repo.tree",
+ owner=repo.owner.canonical_name, repo=repo.name)}}"
+ >browse {{icon("caret-right")}}</a>
+ <a href="{{url_for("repo.log",
+ owner=repo.owner.canonical_name, repo=repo.name)}}"
+ >log {{icon("caret-right")}}</a>
+ </dd>
+ {% endif %}
+ {% if latest_tag %}
+ <dt>{{ latest_tag[0][len("refs/tags/"):] }}</dt>
+ <dd>
+ {% if is_annotated(latest_tag[1]) %}
+ <a href="{{url_for("repo.ref",
+ owner=repo.owner.canonical_name,
+ repo=repo.name, ref=latest_tag[1].name)}}"
+ >release notes {{icon("caret-right")}}</a>
+ {% else %}
+ <a href="{{url_for("repo.tree", owner=repo.owner.canonical_name,
+ repo=repo.name, ref=latest_tag[0][len("refs/tags/"):])}}"
+ >browse {{icon("caret-right")}}</a>
+ <a href="{{url_for("repo.archive", owner=repo.owner.canonical_name,
+ repo=repo.name, ref=latest_tag[0][len("refs/tags/"):])}}"
+ >.tar.gz {{icon("caret-right")}}</a>
+ {% endif %}
+ </dd>
+ {% endif %}
+ </dl>
+ </div>
+ <div class="col-md-8">
+ <h3>clone</h3>
+ {% with read_only, read_write = repo|clone_urls %}
+ <dl>
+ <dt>read-only</dt>
+ <dd><a href="{{read_only}}">{{read_only}}</a></dd>
+ <dt>read/write</dt>
+ <dd>{{read_write}}</dd>
+ </dl>
+ {% endwith %}
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-8 offset-md-4">
+ {% if current_user == repo.owner %}
+ <a
+ href="{{ url_for('mail.send_email_start',
+ owner=repo.owner.canonical_name, repo=repo.name) }}"
+ class="btn btn-primary btn-block"
+ >Prepare a patchset {{icon('caret-right')}}</a>
+ <p class="text-muted text-centered">
+ <small>
+ Use this or <a href="https://git-send-email.io">git
+ send-email</a> to send changes upstream.
+ </small>
+ </p>
+ {% elif current_user != repo.owner %}
+ <form method="POST" action="{{url_for('manage.clone_POST')}}">
+ {{csrf_token()}}
+ <input type="hidden" name="source_repo_id" value="{{repo.id}}" />
+ <button type="submit" class="btn btn-primary btn-block">
+ Clone repo to your account {{icon('caret-right')}}
+ </button>
+ <p class="text-muted text-centered">
+ <small>
+ You can also use your local clone with
+ <a href="https://git-send-email.io">git send-email</a>.
+ </small>
+ </p>
+ </form>
{% else %}
- <a href="{{url_for("repo.tree", owner=repo.owner.canonical_name,
- repo=repo.name, ref=latest_tag[0][len("refs/tags/"):])}}"
- >browse {{icon("caret-right")}}</a>
- <a href="{{url_for("repo.archive", owner=repo.owner.canonical_name,
- repo=repo.name, ref=latest_tag[0][len("refs/tags/"):])}}"
- >.tar.gz {{icon("caret-right")}}</a>
+ <p class="text-centered text-muted">
+ <small>
+ You can contribute to this project without a
+ {{cfg('sr.ht', 'site-name')}}
+ account with
+ <a href="https://git-send-email.io">git send-email</a>,
+ or you can <a
+ href="{{get_origin('meta.sr.ht', external=True)}}"
+ >sign up here</a>.
+ </small>
+ </p>
{% endif %}
- </dd>
- {% endif %}
- </dl>
- </div>
- <div class="col-md-4">
- <h3>clone</h3>
- {% with read_only, read_write = repo|clone_urls %}
- <dl>
- <dt>read-only</dt>
- <dd><a href="{{read_only}}">{{read_only}}</a></dd>
- <dt>read/write</dt>
- <dd>{{read_write}}</dd>
- </dl>
- {% endwith %}
+ </div>
+ </div>
</div>
</div>
{% if readme %}
- <div class="row">
+ <div class="row" id="readme">
<div class="col-md-10">
{{ readme }}
</div>
M gitsrht/templates/utils.html => gitsrht/templates/utils.html +95 -3
@@ 18,16 18,23 @@ endif %}{% endfor %}
{% endmacro %}
{% macro commit_event(repo, c,
- full_body=False, refs={}, full_id=False,
- parents=False, skip_body=False) %}
+ full_body=False, refs={}, full_id=False, diff=None, href=None,
+ parents=False, skip_body=False, target_blank=False) %}
<div>
{% if full_id %}
{{c.id.hex}}
{% else %}
<a
+ {% if href %}
+ href="{{href}}"
+ {% else %}
href="{{url_for("repo.commit", owner=repo.owner.canonical_name,
repo=repo.name, ref=c.id.hex)}}"
+ {% endif %}
title="{{c.id.hex}}"
+ {% if target_blank %}
+ target="_blank"
+ {% endif %}
>{{c.id.hex[:8]}}</a>
{% endif %}
—
@@ 82,9 89,94 @@ endif %}{% endfor %}
</div>
{% if not skip_body %}
{% if full_body %}
-<pre>{{c.message}}</pre>
+<pre>{{c.message}}
+{%- if diff %}
+{{diffstat(diff, anchor=c.oid.hex + "-")}}{% endif -%}
+</pre>
{% else %}
<pre>{{ trim_commit(c.message) }}</pre>
{% endif %}
{% endif %}
{% endmacro %}
+
+{% macro commit_diff(repo, commit, diff, anchor="", target_blank=False) %}
+{# God, working with <pre> tags is such a fucking mess #}
+{% for patch in diff %}
+<pre style="margin-bottom: 0;"
+>{#
+ #}{{patch.delta.status_char()}} {% if parent %}<a
+ href="{{url_for("repo.tree",
+ owner=repo.owner.canonical_name,
+ repo=repo.name,
+ ref=parent.id.hex,
+ path=patch.delta.old_file.path)}}"
+ id="{{anchor}}{{patch.delta.old_file.path}}"
+ {% if target_blank %}
+ target="_blank"
+ {% endif %}
+ >{{patch.delta.old_file.path}}</a>{#
+ #}{% endif %} => {#
+ #}<a
+ href="{{url_for("repo.tree",
+ owner=repo.owner.canonical_name,
+ repo=repo.name,
+ ref=commit.id.hex,
+ path=patch.delta.new_file.path)}}"
+ id="{{anchor}}{{patch.delta.new_file.path}}"
+ {% if target_blank %}
+ target="_blank"
+ {% endif %}
+ >{{patch.delta.new_file.path}}</a>{#
+ #} <span class="pull-right"><span class="text-success">+{{patch.line_stats[1]}}</span>{#
+ #} <span class="text-danger">-{{patch.line_stats[2]}}</span></span>{%
+ if patch.delta.old_file.mode != patch.delta.new_file.mode %}{#
+ #}{#
+ #}{% endif %}</pre>
+<div class="event diff">
+ <pre>{% for hunk in patch.hunks %}
+{% set hunk_index = loop.index %}<strong
+ class="text-info"
+>@@ {#
+#}{% if parent %}<a
+ style="text-decoration: underline"
+ href="{{url_for("repo.tree",
+ owner=repo.owner.canonical_name,
+ repo=repo.name,
+ ref=parent.id.hex,
+ path=patch.delta.old_file.path)}}#L{{hunk.old_start}}"
+ {% if target_blank %}
+ target="_blank"
+ {% endif %}
+>{{hunk.old_start}}</a>,{{hunk.old_lines}} {#
+#}{% endif %}<a
+ style="text-decoration: underline"
+ href="{{url_for("repo.tree",
+ owner=repo.owner.canonical_name,
+ repo=repo.name,
+ ref=commit.id.hex,
+ path=patch.delta.new_file.path)}}#L{{hunk.new_start}}"
+ {% if target_blank %}
+ target="_blank"
+ {% endif %}
+>{{hunk.new_start}}</a>,{{hunk.new_lines}} {#
+#}@@</strong
+>{% if hunk.old_start == 0 %}
+{% endif %}{% for line in hunk.lines
+%}<span class="{{({
+ "+":"text-success",
+ "-":"text-danger",
+ }).get(line.origin) or ""}}"><a
+ href="#{{anchor}}{{patch.delta.old_file.path}}-{{hunk_index}}-{{loop.index}}"
+ id="{{anchor}}{{patch.delta.old_file.path}}-{{hunk_index}}-{{loop.index}}"
+ style="color: inherit"
+>{{line.origin}}</a>{%
+ if loop.first and hunk.old_start != 0
+%}{{line.content.lstrip()}}{%
+ else
+%} {{line.content}}{%
+ endif
+%}</span>{% endfor %}
+{% endfor %}</pre>
+</div>
+{% endfor %}
+{% endmacro %}
M gitsrht/types/__init__.py => gitsrht/types/__init__.py +1 -0
@@ 1,3 1,4 @@
+import sqlalchemy as sa
from srht.database import Base
from srht.oauth import ExternalUserMixin, ExternalOAuthTokenMixin
from scmsrht.repos import BaseAccessMixin, BaseRedirectMixin
M scss/main.scss => scss/main.scss +89 -0
@@ 215,3 215,92 @@ img {
}
}
}
+
+.prepare-patchset {
+ legend {
+ font-weight: bold;
+ }
+
+ label {
+ margin-right: 1rem;
+ cursor: pointer;
+ }
+
+ details {
+ display: inline;
+ color: $gray-600;
+
+ &[open] {
+ display: block;
+ color: $black;
+
+ summary {
+ color: $black;
+ }
+ }
+
+ ul {
+ list-style: none;
+ padding-left: 0;
+ }
+
+ li {
+ margin-top: 1rem;
+ }
+ }
+
+ .event-list {
+ display: flex;
+ flex-direction: column;
+
+ &.reverse {
+ flex-direction: column-reverse;
+ }
+
+ input[type="radio"] {
+ display: none;
+ }
+
+ & > .commit-diff {
+ margin-top: 1rem;
+ order: -2;
+ }
+
+ & > .form-controls {
+ order: -1;
+ margin-top: 1rem;
+ align-self: flex-end;
+
+ &.last {
+ order: -3;
+ }
+ }
+
+ & > details {
+ order: 0;
+ }
+
+ & > .event {
+ order: 1;
+ display: block;
+ margin: 0.25rem 0;
+
+ // Because the order is reversed
+ &:last-child {
+ margin: 0.25rem 0;
+ }
+
+ &:first-child {
+ margin: 0;
+ }
+ }
+
+ input[type="radio"]:checked ~ .event {
+ background: lighten($info, 50) !important;
+ }
+
+ input[type="radio"]:checked + .event {
+ background: lighten($info, 45) !important;
+ }
+ }
+}