7c1d43a6417e6288419e825b3be0de58efee208a — Drew DeVault a month ago 9d9c9ff 0.34.0
Implement send-email helper UI
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 @@
                 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 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 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 @@
                 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 @@
         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 _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 @@
 
     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 @@
             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 @@
                 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 @@
         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 %} =&gt; {#
-         #}<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 @@
 {% 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 %}
   &mdash;


@@ 82,9 89,94 @@
 </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 %} =&gt; {#
+ #}<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 @@
     }
   }
 }
+
+.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;
+    }
+  }
+}