~sircmpwn/git.sr.ht

7c1d43a6417e6288419e825b3be0de58efee208a — Drew DeVault 7 months 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 @@ 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 %} =&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 @@ 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 %}
  &mdash;


@@ 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 %} =&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 @@ 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;
    }
  }
}