~edwargix/git.sr.ht

bb9baf658485c10d9d9a3e2693efe1dd236a28a1 — Adnan Maolood 2 years ago c06bc64
gitsrht: Use GraphQL for repository updates/deletion

Rewrite the web frontend to use GraphQL mutations where necessary to
ensure that GraphQL user webhooks are delivered. Most of the routes
defined by scm.sr.ht became part of git.sr.ht. A separate commit will
remove those routes from scm.sr.ht to avoid conflicts.

As a bonus, a new clone endpoint was added to facilitate easy cloning of
third-party repositories. A clone button was added to the user dashboard
to make this functionality easily accessible.
M gitsrht/app.py => gitsrht/app.py +4 -1
@@ 23,13 23,16 @@ class GitApp(ScmSrhtFlask):
                repository_class=Repository, user_class=User,
                repo_api=GitRepoApi(), oauth_service=oauth_service)

        from gitsrht.blueprints.api import plumbing, porcelain
        from gitsrht.blueprints.api import register_api
        from gitsrht.blueprints.api.plumbing import plumbing
        from gitsrht.blueprints.api.porcelain import porcelain
        from gitsrht.blueprints.artifacts import artifacts
        from gitsrht.blueprints.email import mail
        from gitsrht.blueprints.manage import manage
        from gitsrht.blueprints.repo import repo
        from srht.graphql import gql_blueprint

        register_api(self)
        self.register_blueprint(plumbing)
        self.register_blueprint(porcelain)
        self.register_blueprint(mail)

M gitsrht/blueprints/api/__init__.py => gitsrht/blueprints/api/__init__.py +48 -2
@@ 1,2 1,48 @@
from gitsrht.blueprints.api.plumbing import plumbing
from gitsrht.blueprints.api.porcelain import porcelain
import pkg_resources
from flask import abort
from scmsrht.access import UserAccess, get_access
from scmsrht.types import Repository, User
from srht.flask import csrf_bypass
from srht.oauth import current_token, oauth

def get_user(username):
    user = None
    if username == None:
        user = current_token.user
    elif username.startswith("~"):
        user = User.query.filter(User.username == username[1:]).one_or_none()
    if not user:
        abort(404)
    return user

def get_repo(owner, reponame, needs=UserAccess.read):
    repo = (Repository.query
            .filter(Repository.owner_id == owner.id)
            .filter(Repository.name == reponame)).one_or_none()
    if not repo:
        abort(404)
    access = get_access(repo, user=current_token.user)
    if needs not in access:
        abort(403)
    return repo

def register_api(app):
    from gitsrht.blueprints.api.info import info

    app.register_blueprint(info)
    csrf_bypass(info)

    @app.route("/api/version")
    def version():
        try:
            dist = pkg_resources.get_distribution("gitsrht")
            return { "version": dist.version }
        except:
            return { "version": "unknown" }

    @app.route("/api/user/<username>")
    @app.route("/api/user", defaults={"username": None})
    @oauth(None)
    def user_GET(username):
        user = get_user(username)
        return user.to_dict()

A gitsrht/blueprints/api/info.py => gitsrht/blueprints/api/info.py +158 -0
@@ 0,0 1,158 @@
from flask import Blueprint, Response, current_app, request
from scmsrht.access import UserAccess
from scmsrht.repos import RepoVisibility
from scmsrht.types import Access, Repository, User
from gitsrht.blueprints.api import get_user, get_repo
from srht.api import paginated_response
from srht.database import db
from srht.graphql import exec_gql
from srht.oauth import current_token, oauth
from srht.validation import Validation
from sqlalchemy import and_, or_

info = Blueprint("api_info", __name__)

@info.route("/api/repos", defaults={"username": None})
@info.route("/api/<username>/repos")
@oauth("info:read")
def repos_by_user_GET(username):
    user = get_user(username)
    repos = (Repository.query
            .filter(Repository.owner_id == user.id)
            .filter(Repository.visibility != RepoVisibility.autocreated))
    if user.id != current_token.user_id:
        repos = (repos
                .outerjoin(Access._get_current_object(),
                    Access.repo_id == Repository.id)
                .filter(or_(
                    Access.user_id == current_token.user_id,
                    and_(
                        Repository.visibility == RepoVisibility.public,
                        Access.id.is_(None))
                )))
    return paginated_response(Repository.id, repos)

@info.route("/api/repos", methods=["POST"])
@oauth("info:write")
def repos_POST():
    valid = Validation(request)
    user = current_token.user
    resp = current_app.repo_api.create_repo(valid, user)
    if not valid.ok:
        return valid.response
    return resp, 201

@info.route("/api/repos/<reponame>", defaults={"username": None})
@info.route("/api/<username>/repos/<reponame>")
@oauth("info:read")
def repos_by_name_GET(username, reponame):
    user = get_user(username)
    repo = get_repo(user, reponame)
    return repo.to_dict()

@info.route("/api/repos/<reponame>", methods=["PUT"])
@oauth("info:write")
def repos_by_name_PUT(reponame):
    valid = Validation(request)
    user = current_token.user
    repo = get_repo(user, reponame, needs=UserAccess.manage)

    rewrite = lambda value: None if value == "" else value
    input = {
        key: rewrite(valid.source[key]) for key in [
            "name", "description", "visibility",
        ] if valid.source.get(key) is not None
    }

    # Visibility must be uppercase
    if "visibility" in input:
        input["visibility"] = input["visibility"].upper()

    resp = exec_gql(current_app.site, """
        mutation UpdateRepository($id: Int!, $input: RepoInput!) {
            updateRepository(id: $id, input: $input) {
                id
                created
                updated
                name
                owner {
                    canonicalName
                    ... on User {
                        name: username
                    }
                }
                description
                visibility
            }
        }
    """, user=user, valid=valid, id=repo.id, input=input)

    if not valid.ok:
        return valid.response

    resp = resp["updateRepository"]
    # Convert visibility back to lowercase
    resp["visibility"] = resp["visibility"].lower()
    return resp

@info.route("/api/repos/<reponame>", methods=["DELETE"])
@oauth("info:write")
def repos_by_name_DELETE(reponame):
    user = current_token.user
    repo = get_repo(user, reponame, needs=UserAccess.manage)
    repo_id = repo.id
    current_app.repo_api.delete_repo(repo, user)
    return {}, 204

@info.route("/api/repos/<reponame>/readme", defaults={"username": None})
@info.route("/api/<username>/repos/<reponame>/readme")
@oauth("info:read")
def repos_by_name_readme_GET(username, reponame):
    user = get_user(username)
    repo = get_repo(user, reponame)

    if repo.readme is None:
        return {}, 404
    else:
        return Response(repo.readme, mimetype="text/plain")

@info.route("/api/repos/<reponame>/readme", methods=["PUT"])
@oauth("info:write")
def repos_by_name_readme_PUT(reponame):
    user = current_token.user
    repo = get_repo(user, reponame, needs=UserAccess.manage)

    valid = Validation(request)
    if request.content_type != 'text/html':
        return valid.error("not text/html", field="content-type")

    readme = None
    try:
        readme = request.data.decode("utf-8")
    except:
        return valid.error("README files must be UTF-8 encoded", field="body")

    resp = exec_gql(current_app.site, """
        mutation UpdateRepository($id: Int!, $readme: String!) {
            updateRepository(id: $id, input: {readme: $readme}) { id }
        }
    """, user=user, valid=valid, id=repo.id, readme=readme)

    if not valid.ok:
        return valid.response
    return {}, 204

@info.route("/api/repos/<reponame>/readme", methods=["DELETE"])
@oauth("info:write")
def repos_by_name_readme_DELETE(reponame):
    user = current_token.user
    repo = get_repo(user, reponame, needs=UserAccess.manage)
    valid = Validation(request)
    exec_gql(current_app.site, """
        mutation UpdateRepository($id: Int!) {
            updateRepository(id: $id, input: {readme: null}) { id }
        }
    """, user=user, valid=valid, id=repo.id)
    if not valid.ok:
        return valid.response
    return {}, 204

M gitsrht/blueprints/api/plumbing.py => gitsrht/blueprints/api/plumbing.py +1 -1
@@ 3,7 3,7 @@ import binascii
import pygit2
from flask import Blueprint, Response, abort, request
from gitsrht.git import Repository as GitRepository
from scmsrht.blueprints.api import get_user, get_repo
from gitsrht.blueprints.api import get_user, get_repo
from srht.oauth import oauth

plumbing = Blueprint("api_plumbing", __name__)

M gitsrht/blueprints/api/porcelain.py => gitsrht/blueprints/api/porcelain.py +1 -1
@@ 11,7 11,7 @@ from gitsrht.webhooks import RepoWebhook
from io import BytesIO
from itertools import groupby
from scmsrht.access import UserAccess
from scmsrht.blueprints.api import get_user, get_repo
from gitsrht.blueprints.api import get_user, get_repo
from srht.api import paginated_response
from srht.database import db
from srht.oauth import current_token, oauth

M gitsrht/blueprints/manage.py => gitsrht/blueprints/manage.py +227 -30
@@ 1,47 1,244 @@
import pygit2
from flask import Blueprint, request, render_template
from flask import Blueprint, current_app, request, render_template, abort
from flask import redirect, url_for
from gitsrht.git import Repository as GitRepository
from srht.config import cfg
from srht.database import db
from srht.oauth import loginrequired
from srht.flask import session
from srht.graphql import exec_gql, GraphQLError
from srht.oauth import current_user, loginrequired, UserType
from srht.validation import Validation
from scmsrht.access import check_access, UserAccess
from scmsrht.repos.access import AccessMode
from scmsrht.repos.redirect import BaseRedirectMixin
from scmsrht.repos.repository import RepoVisibility
from scmsrht.webhooks import UserWebhook
from scmsrht.types import Access, User
import shutil
import os

manage = Blueprint('manage_git', __name__)
manage = Blueprint('manage', __name__)

@manage.route("/<owner_name>/<repo_name>/settings/info_git", methods=["POST"])
@manage.route("/create")
@loginrequired
def settings_info_git_POST(owner_name, repo_name):
def create_GET():
    another = request.args.get("another")
    name = request.args.get("name")
    return render_template("create.html", another=another, repo_name=name)

@manage.route("/create", methods=["POST"])
@loginrequired
def create_POST():
    if not current_app.repo_api:
        abort(501)
    valid = Validation(request)
    resp = current_app.repo_api.create_repo(valid)
    if not valid.ok:
        return render_template("create.html", **valid.kwargs)

    another = valid.optional("another")
    if another == "on":
        return redirect("/create?another")
    else:
        return redirect(url_for("repo.summary",
            owner=current_user.canonical_name, repo=resp["name"]))

@manage.route("/clone")
@loginrequired
def clone():
    another = request.args.get("another")
    return render_template("clone.html", another=another, visibility="UNLISTED")

@manage.route("/clone", methods=["POST"])
@loginrequired
def clone_POST():
    if not current_app.repo_api:
        abort(501)
    valid = Validation(request)
    resp = current_app.repo_api.clone_repo(valid)
    if not valid.ok:
        return render_template("clone.html", **valid.kwargs)
    return redirect(url_for("repo.summary",
        owner=current_user.canonical_name, repo=resp["name"]))

@manage.route("/<owner_name>/<repo_name>/settings/info")
@loginrequired
def settings_info(owner_name, repo_name):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, BaseRedirectMixin):
        return redirect(url_for(".settings_info",
            owner_name=owner_name, repo_name=repo.new_repo.name))
    return render_template("settings_info.html", owner=owner, repo=repo)

@manage.route("/<owner_name>/<repo_name>/settings/info", methods=["POST"])
@loginrequired
def settings_info_POST(owner_name, repo_name):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, BaseRedirectMixin):
        repo = repo.new_repo

    # TODO: GraphQL mutation to set default branch name
    valid = Validation(request)
    desc = valid.optional("description", default=repo.description)
    visibility = valid.optional("visibility",
            cls=RepoVisibility,
            default=repo.visibility)
    branch = valid.optional("default_branch_name")
    with GitRepository(repo.path) as git_repo:
        new_default_branch = None
        if branch:
            try:
                new_default_branch = git_repo.branches.get(branch)
            except pygit2.InvalidSpecError:
    if branch:
        with GitRepository(repo.path) as git_repo:
            new_default_branch = git_repo.branches.get(branch)
            if new_default_branch:
                head_ref = git_repo.lookup_reference("HEAD")
                head_ref.set_target(new_default_branch.name)
            else:
                valid.error(f"Branch {branch} not found", field="default_branch_name")
        if not valid.ok:
            return render_template("settings_info.html",
                    owner=owner, repo=repo, **valid.kwargs)
        if new_default_branch:
            head_ref = git_repo.lookup_reference("HEAD")
            head_ref.set_target(new_default_branch.name)

        repo.visibility = visibility
        repo.description = desc
        UserWebhook.deliver(UserWebhook.Events.repo_update,
                repo.to_dict(), UserWebhook.Subscription.user_id == repo.owner_id)
        db.session.commit()
        return redirect(url_for("manage.settings_info",
            owner_name=owner_name, repo_name=repo_name))
                return render_template("settings_info.html",
                        owner=owner, repo=repo, **valid.kwargs)

    rewrite = lambda value: None if value == "" else value
    input = {
        key: rewrite(valid.source[key]) for key in [
            "description", "visibility",
        ] if valid.source.get(key) is not None
    }

    resp = exec_gql("git.sr.ht", """
        mutation UpdateRepository($id: Int!, $input: RepoInput!) {
            updateRepository(id: $id, input: $input) { id }
        }
    """, valid=valid, id=repo.id, input=input)
    if not valid.ok:
        return render_template("settings_info.html",
                owner=owner, repo=repo, **valid.kwargs)

    return redirect(url_for("manage.settings_info",
        owner_name=owner_name, repo_name=repo_name))

@manage.route("/<owner_name>/<repo_name>/settings/rename")
@loginrequired
def settings_rename(owner_name, repo_name):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, BaseRedirectMixin):
        return redirect(url_for(".settings_rename",
            owner_name=owner_name, repo_name=repo.new_repo.name))
    return render_template("settings_rename.html", owner=owner, repo=repo)

@manage.route("/<owner_name>/<repo_name>/settings/rename", methods=["POST"])
@loginrequired
def settings_rename_POST(owner_name, repo_name):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, BaseRedirectMixin):
        repo = repo.new_repo

    valid = Validation(request)
    name = valid.require("name")
    if not valid.ok:
        return render_template("settings_rename.html", owner=owner, repo=repo,
                **valid.kwargs)

    resp = None
    try:
        resp = exec_gql("git.sr.ht", """
            mutation RenameRepository($id: Int!, $name: String!) {
                updateRepository(id: $id, input: {name: $name}) {
                    name
                }
            }
        """, id=repo.id, name=name)
    except GraphQLError as e:
        for err in e.errors:
            valid.error(err["message"], field="name")

    if not valid.ok:
        return render_template("settings_rename.html", owner=owner, repo=repo,
                **valid.kwargs)
    resp = resp["updateRepository"]
    return redirect(url_for("repo.summary",
        owner=owner_name, repo=resp["name"]))

@manage.route("/<owner_name>/<repo_name>/settings/access")
@loginrequired
def settings_access(owner_name, repo_name):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, BaseRedirectMixin):
        return redirect(url_for(".settings_manage",
            owner_name=owner_name, repo_name=repo.new_repo.name))
    return render_template("settings_access.html", owner=owner, repo=repo)

@manage.route("/<owner_name>/<repo_name>/settings/access", methods=["POST"])
@loginrequired
def settings_access_POST(owner_name, repo_name):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, BaseRedirectMixin):
        repo = repo.new_repo
    valid = Validation(request)
    username = valid.require("user", friendly_name="User")
    mode = valid.optional("access", cls=AccessMode, default=AccessMode.ro)
    if not valid.ok:
        return render_template("settings_access.html",
                owner=owner, repo=repo, **valid.kwargs)
    # TODO: Group access
    if username[0] == "~":
        username = username[1:]
    try:
        user = current_app.oauth_service.lookup_user(username)
    except:
        user = None
    valid.expect(user, "User not found.", field="user")
    valid.expect(not user or user.id != current_user.id,
            "You can't adjust your own access controls. You always have full read/write access.",
            field="user")
    valid.expect(not user or user.user_type != UserType.unconfirmed,
            "This account has not been confirmed yet.", field="user")
    valid.expect(not user or user.user_type != UserType.suspended,
            "This account has been suspended.", field="user")
    if not valid.ok:
        return render_template("settings_access.html",
                owner=owner, repo=repo, **valid.kwargs)
    grant = (Access.query
        .filter(Access.repo_id == repo.id, Access.user_id == user.id)
    ).first()
    if not grant:
        grant = Access()
        grant.repo_id = repo.id
        grant.user_id = user.id
        db.session.add(grant)
    grant.mode = mode
    db.session.commit()
    return redirect(url_for("manage.settings_access",
        owner_name=owner.canonical_name, repo_name=repo.name))

@manage.route("/<owner_name>/<repo_name>/settings/access/revoke/<grant_id>", methods=["POST"])
@loginrequired
def settings_access_revoke_POST(owner_name, repo_name, grant_id):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, BaseRedirectMixin):
        repo = repo.new_repo
    grant = (Access.query
        .filter(Access.repo_id == repo.id, Access.id == grant_id)
    ).first()
    if not grant:
        abort(404)
    db.session.delete(grant)
    db.session.commit()
    return redirect("/{}/{}/settings/access".format(
        owner.canonical_name, repo.name))

@manage.route("/<owner_name>/<repo_name>/settings/delete")
@loginrequired
def settings_delete(owner_name, repo_name):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, BaseRedirectMixin):
        return redirect(url_for(".settings_delete",
            owner_name=owner_name, repo_name=repo.new_repo.name))
    return render_template("settings_delete.html", owner=owner, repo=repo)

@manage.route("/<owner_name>/<repo_name>/settings/delete", methods=["POST"])
@loginrequired
def settings_delete_POST(owner_name, repo_name):
    if not current_app.repo_api:
        abort(501)
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, BaseRedirectMixin):
        # Normally we'd redirect but we don't want to fuck up some other repo
        abort(404)
    repo_id = repo.id
    current_app.repo_api.delete_repo(repo)
    session["notice"] = "{}/{} was deleted.".format(
        owner.canonical_name, repo.name)
    return redirect("/" + owner.canonical_name)

M gitsrht/repos.py => gitsrht/repos.py +65 -38
@@ 1,12 1,15 @@
import hashlib
import os.path
import pygit2
import re
import shutil
import subprocess
from gitsrht.types import Artifact, Repository, Redirect
from minio import Minio
from scmsrht.repos import SimpleRepoApi
from scmsrht.repos.repository import RepoVisibility
from srht.config import cfg
from srht.database import db
from srht.graphql import exec_gql, GraphQLError
from werkzeug.utils import secure_filename

repos_path = cfg("git.sr.ht", "repos")


@@ 75,44 78,68 @@ def upload_artifact(valid, repo, commit, f, filename):
    db.session.add(artifact)
    return artifact

class GitRepoApi(SimpleRepoApi):
    def __init__(self):
        super().__init__(repos_path,
                redirect_class=Redirect,
                repository_class=Repository)
# TODO: Remove repo API wrapper class

    def do_init_repo(self, owner, repo):
        # Note: update gitsrht-shell when changing this,
        # do_clone_repo(), or _repo_config_init()
        git_repo = pygit2.init_repository(repo.path, bare=True,
            flags=pygit2.GIT_REPOSITORY_INIT_BARE |
                  pygit2.GIT_REPOSITORY_INIT_MKPATH)
        self._repo_config_init(repo, git_repo)
class GitRepoApi():
    def get_repo_path(self, owner, repo_name):
        return os.path.join(repos_path, "~" + owner.username, repo_name)

    def _repo_config_init(self, repo, git_repo):
        git_repo.config["srht.repo-id"] = repo.id
        # We handle this ourselves in the post-update hook, and git's
        # default behaviour is to print a large notice and reject the push entirely
        git_repo.config["receive.denyDeleteCurrent"] = "ignore"
        git_repo.config["receive.advertisePushOptions"] = True
        os.unlink(os.path.join(repo.path, "info", "exclude"))
        os.unlink(os.path.join(repo.path, "hooks", "README.sample"))
        os.unlink(os.path.join(repo.path, "description"))
        os.symlink(post_update, os.path.join(repo.path, "hooks", "pre-receive"))
        os.symlink(post_update, os.path.join(repo.path, "hooks", "update"))
        os.symlink(post_update, os.path.join(repo.path, "hooks", "post-update"))
    def create_repo(self, valid, user=None):
        repo_name = valid.require("name", friendly_name="Name")
        description = valid.optional("description")
        visibility = valid.optional("visibility")
        if not valid.ok:
            return None

    def do_delete_repo(self, repo):
        from gitsrht.webhooks import RepoWebhook
        RepoWebhook.Subscription.query.filter(
                RepoWebhook.Subscription.repo_id == repo.id).delete()
        # TODO: Should we delete these asynchronously?
        for artifact in (Artifact.query
                .filter(Artifact.user_id == repo.owner_id)
                .filter(Artifact.repo_id == repo.id)):
            delete_artifact(artifact)
        super().do_delete_repo(repo)
        resp = exec_gql("git.sr.ht", """
            mutation CreateRepository($name: String!, $visibility: Visibility = PUBLIC, $description: String) {
                createRepository(name: $name, visibility: $visibility, description: $description) {
                    id
                    created
                    updated
                    name
                    owner {
                        canonicalName
                        ... on User {
                            name: username
                        }
                    }
                    description
                    visibility
                }
            }
        """, valid=valid, user=user, name=repo_name, description=description, visibility=visibility)

    def do_clone_repo(self, source, repo):
        git_repo = pygit2.clone_repository(source, repo.path, bare=True)
        self._repo_config_init(repo, git_repo)
        print(valid)

        if not valid.ok:
            return None
        return resp["createRepository"]

    def clone_repo(self, valid):
        cloneUrl = valid.require("cloneUrl", friendly_name="Clone URL")
        name = valid.require("name", friendly_name="Name")
        description = valid.optional("description")
        visibility = valid.optional("visibility")
        if not valid.ok:
            return None

        resp = exec_gql("git.sr.ht", """
            mutation CreateRepository($name: String!, $visibility: Visibility = UNLISTED, $description: String, $cloneUrl: String) {
                createRepository(name: $name, visibility: $visibility, description: $description, cloneUrl: $cloneUrl) {
                    name
                }
            }
        """, valid=valid, name=name, visibility=visibility,
            description=description, cloneUrl=cloneUrl)

        if not valid.ok:
            return None
        return resp["createRepository"]

    def delete_repo(self, repo, user=None):
        exec_gql("git.sr.ht", """
            mutation DeleteRepository($id: Int!) {
                deleteRepository(id: $id) { id }
            }
        """, user=user, id=repo.id)

A gitsrht/templates/clone.html => gitsrht/templates/clone.html +105 -0
@@ 0,0 1,105 @@
{% extends "base.html" %}

{% block content %}
<div class="container">
  <div class="row">
    <section class="col-md-6">
      <h3 id="create">Clone existing repository</h3>
      <form method="POST" action="/clone">
        {{csrf_token()}}
        <div class="form-group">
          <label for="cloneUrl">Clone URL</label>
          <input
            {% if another %}
            autofocus
            {% endif %}
            type="text"
            name="cloneUrl"
            id="cloneUrl"
            class="form-control {{valid.cls("cloneUrl")}}"
            value="{{ cloneUrl or "" }}" />
          {{valid.summary("cloneUrl")}}
        </div>
        <div class="form-group">
          <label for="name">Name</label>
          <input
            type="text"
            name="name"
            id="name"
            class="form-control {{valid.cls("name")}}"
            value="{{ name or "" }}" />
          {{valid.summary("name")}}
        </div>
        <div class="form-group">
          <label for="description">Description</label>
          <input
            type="text"
            name="description"
            id="description"
            class="form-control {{valid.cls("description")}}"
            value="{{ description or "" }}" />
          {{valid.summary("description")}}
        </div>
        <fieldset class="form-group">
          <legend>Visibility</legend>
          <div class="form-check form-check-inline">
            <label
              class="form-check-label"
              title="Publically visible and listed on your profile"
            >
              <input
                class="form-check-input"
                type="radio"
                name="visibility"
                value="PUBLIC"
                {{ "checked" if visibility == "PUBLIC" else "" }}
              > Public
            </label>
          </div>
          <div class="form-check form-check-inline">
            <label
                class="form-check-label"
                title="Visible to anyone with the link, but not shown on your profile"
            >
              <input
                class="form-check-input"
                type="radio"
                name="visibility"
                value="UNLISTED"
                {{ "checked" if visibility == "UNLISTED" else "" }}
              > Unlisted
            </label>
          </div>
          <div class="form-check form-check-inline">
            <label
              class="form-check-label"
              title="Only visible to you and your collaborators"
            >
              <input
                class="form-check-input"
                type="radio"
                name="visibility"
                value="PRIVATE"
                {{ "checked" if visibility == "PRIVATE" else "" }}
              > Private
            </label>
          </div>
        </fieldset>
        <button type="submit" class="btn btn-primary">
          Clone {{icon("caret-right")}}
        </button>
        <label class="form-check-label" style="margin-left: 2rem">
          <input
            class="form-check-input"
            type="checkbox"
            name="another"
            style="position: relative; top: 2px;"
            {% if another %}
            checked
            {% endif %}> Clone another?
        </label>
      </form>
    </section>
  </div>
</div>
{% endblock %}

A gitsrht/templates/create.html => gitsrht/templates/create.html +97 -0
@@ 0,0 1,97 @@
{% extends "base.html" %}

{% block content %}
<div class="container">
  <div class="row">
    <section class="col-md-6">
      <h3 id="create">Create new repository</h3>
      <form method="POST" action="/create">
        {{csrf_token()}}
        <div class="form-group">
          <label for="name">Name</label>
          <input
            {% if another %}
            autofocus
            {% endif %}
            type="text"
            name="name"
            id="name"
            class="form-control {{valid.cls("name")}}"
            value="{{ repo_name or "" }}" />
          {{valid.summary("name")}}
        </div>
        <div class="form-group">
          <label for="description">Description</label>
          <input
            type="text"
            name="description"
            id="description"
            class="form-control {{valid.cls("description")}}"
            value="{{ repo_description or "" }}" />
          {{valid.summary("description")}}
        </div>
        <fieldset class="form-group">
          <legend>Visibility</legend>
          <div class="form-check form-check-inline">
            <label
              class="form-check-label"
              title="Publically visible and listed on your profile"
            >
              <input
                class="form-check-input"
                type="radio"
                name="visibility"
                value="PUBLIC"
                checked> Public
            </label>
          </div>
          <div class="form-check form-check-inline">
            <label
                class="form-check-label"
                title="Visible to anyone with the link, but not shown on your profile"
              >
              <input
                class="form-check-input"
                type="radio"
                name="visibility"
                value="UNLISTED"> Unlisted
            </label>
          </div>
          <div class="form-check form-check-inline">
            <label
              class="form-check-label"
              title="Only visible to you and your collaborators"
            >
              <input
                class="form-check-input"
                type="radio"
                name="visibility"
                value="PRIVATE"> Private
            </label>
          </div>
        </fieldset>
        <button type="submit" class="btn btn-primary">
          Create {{icon("caret-right")}}
        </button>
        <label class="form-check-label" style="margin-left: 2rem">
          <input
            class="form-check-input"
            type="checkbox"
            name="another"
            style="position: relative; top: 2px;"
            {% if another %}
            checked
            {% endif %}> Create another?
        </label>
      </form>
    </section>
    <section class="col-md-8">
      <div class="alert alert-info">
        <strong>Did you know?</strong> If you remember how our clone URLs are
        written, you can push to repositories that don't exist &mdash; they'll
        be automatically created for you.
      </div>
    </section>
  </div>
</div>
{% endblock %}

M gitsrht/templates/dashboard.html => gitsrht/templates/dashboard.html +55 -5
@@ 1,6 1,56 @@
{% extends "bases/scmdashboard.html" %}
{% block welcome_user %}
  Welcome back, {{ current_user.username }}! This is your git hosting service.
  Documentation for its use is
  <a href="https://man.sr.ht/git.sr.ht">available here</a>.
{% extends "base.html" %}

{% block content %}
<div class="container">
  <div class="row">
    <div class="col-md-4">
      <p>
        Welcome back, {{ current_user.username }}! This is your git hosting service.
        Documentation for its use is
        <a href="https://man.sr.ht/git.sr.ht">available here</a>.
      </p>
      <a
        href="{{url_for('manage.create_GET')}}"
        class="btn btn-primary btn-block"
      >Create new repository {{icon("caret-right")}}</a>
      <a
        href="{{url_for('manage.clone')}}"
        class="btn btn-default btn-block"
      >Clone existing repository {{icon("caret-right")}}</a>
    </div>
    <div class="col-md-8">
      <hr class="d-md-none" />
      {% if repos and len(repos) %}
      <div class="event-list">
        {% for repo in repos %}
        <div class="event">
          <h4>
            <a
              href="{{url_for('repo.summary',
                     owner=current_user.canonical_name,
                     repo=repo.name)}}"
            >~{{current_user.username}}/{{repo.name}}</a>
            {% if repo.visibility.value != 'public' %}
            <small class="pull-right">
              {{ repo.visibility.value }}
            </small>
            {% endif %}
          </h4>
          {% if repo.description %}
          <p>{{ repo.description }}</p>
          {% endif %}
        </div>
        {% endfor %}
      </div>
      <a
        href="{{url_for('public.user_index',
        username=current_user.username)}}"
        class="btn btn-default pull-right"
      >More on your profile {{icon("caret-right")}}</a>
      {% else %}
      <p class="text-muted">You don't have any repositories.</p>
      {% endif %}
    </div>
  </div>
</div>
{% endblock %}

M gitsrht/templates/settings_info.html => gitsrht/templates/settings_info.html +106 -27
@@ 1,30 1,109 @@
{% set info_action=url_for("manage_git.settings_info_git_POST",
                    owner_name=owner.canonical_name, repo_name=repo.name) %}
{% extends "bases/scmsettings_info.html" %}
{% extends "settings.html" %}

{% block extrafields %}
<div class="form-group">
  <label for="default_branch_name">
    Default branch
  </label>
  <select
    class="form-control {{valid.cls('default_branch_name')}}"
    id="default_branch_name"
    name="default_branch_name"
    {% if repo.git_repo.is_empty %}disabled{% endif %}
  >
    {% set default_branch_name = repo.git_repo.default_branch_name() or "" %}
    {% for branch in repo.git_repo.raw_listall_branches() %}
      {% set branch = branch.decode("utf-8", "replace") %}
      <option
        value="{{branch}}"
        {% if branch == default_branch_name %}
          selected
        {% endif %}>{{branch}}</option>
    {% else %}
      <option>No branches</option>
    {% endfor %}
  </select>
  {{valid.summary('default_branch_name')}}
{% block content %}
<div class="row">
  <div class="col-md-8">
    <form method="POST" action="{{url_for("manage.settings_info_POST",
        owner_name=owner.canonical_name, repo_name=repo.name)}}">
      {{csrf_token()}}
      <div class="form-group">
        <label for="name" style="display: block">
          Repository name
          <a
            href="/{{ owner.canonical_name }}/{{ repo.name }}/settings/rename"
            class="pull-right"
          >Rename?</a>
        </label>
        <input
          type="text"
          class="form-control"
          id="name"
          value="{{repo.name}}"
          readonly />
      </div>
      <div class="form-group">
        <label for="description">
          Description
        </label>
        <input
          type="text"
          class="form-control"
          id="description"
          name="description"
          value="{{repo.description or ""}}" />
      </div>
      <fieldset class="form-group">
        <div class="form-check form-check-inline">
          <label
            class="form-check-label"
            title="Publically visible and listed on your profile"
          >
            <input
              class="form-check-input"
              type="radio"
              name="visibility"
              value="PUBLIC"
              {{ "checked" if repo.visibility.value == "public" else "" }}
            > Public
          </label>
        </div>
        <div class="form-check form-check-inline">
          <label
              class="form-check-label"
              title="Visible to anyone with the link, but not shown on your profile"
            >
            <input
              class="form-check-input"
              type="radio"
              name="visibility"
              value="UNLISTED"
              {{ "checked" if repo.visibility.value == "unlisted" else "" }}
            > Unlisted
          </label>
        </div>
        <div class="form-check form-check-inline">
          <label
            class="form-check-label"
            title="Only visible to you and your collaborators"
          >
            <input
              class="form-check-input"
              type="radio"
              name="visibility"
              value="PRIVATE"
              {{ "checked" if repo.visibility.value == "private" else "" }}
            > Private
          </label>
        </div>
      </fieldset>
      <div class="form-group">
        <label for="default_branch_name">
          Default branch
        </label>
        <select
          class="form-control {{valid.cls('default_branch_name')}}"
          id="default_branch_name"
          name="default_branch_name"
          {% if repo.git_repo.is_empty %}disabled{% endif %}
        >
          {% set default_branch_name = repo.git_repo.default_branch_name() or "" %}
          {% for branch in repo.git_repo.raw_listall_branches() %}
            {% set branch = branch.decode("utf-8", "replace") %}
            <option
              value="{{branch}}"
              {% if branch == default_branch_name %}
                selected
              {% endif %}>{{branch}}</option>
          {% else %}
            <option>No branches</option>
          {% endfor %}
        </select>
        {{valid.summary('default_branch_name')}}
      </div>
      <button type="submit" class="btn btn-primary pull-right">
        Save {{icon("caret-right")}}
      </button>
    </form>
  </div>
</div>
{% endblock %}

M gitsrht/templates/summary.html => gitsrht/templates/summary.html +4 -1
@@ 89,7 89,10 @@
          {% 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}}" />
            <input type="hidden" name="cloneUrl" value="{{(repo|clone_urls)[0]}}" />
            <input type="hidden" name="name" value="{{repo.name}}" />
            <input type="hidden" name="description" value="Clone of {{(repo|clone_urls)[0]}}" />
            <input type="hidden" name="visibility" value="{% if repo.visibility.value == 'private' %}PRIVATE{% else %}UNLISTED{% endif %}" />
            <button type="submit" class="btn btn-primary btn-block">
              Clone repo to your account {{icon('caret-right')}}
            </button>

M gitsrht/webhooks.py => gitsrht/webhooks.py +0 -1
@@ 8,7 8,6 @@ if not hasattr(db, "session"):
from srht.webhook import Event
from srht.webhook.celery import CeleryWebhook, make_worker
from srht.metrics import RedisQueueCollector
from scmsrht.webhooks import UserWebhook
import sqlalchemy as sa

webhook_broker = cfg("git.sr.ht", "webhooks")