Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature/improve GitHub App installation #270

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions lifemonitor/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,13 @@ def get(self, key: str, prefix: str = CACHE_PREFIX):
logger.debug("Current cache data: %r", data is not None)
return pickle.loads(data) if data is not None else data

def delete(self, key: str, prefix: str = CACHE_PREFIX):
logger.debug(f"Deleting key: {key}")
if self.cache_enabled:
logger.debug("Redis backend detected!")
logger.debug(f"Pattern: {prefix}{key}")
self.backend.delete(self._make_key(key, prefix=prefix))

def delete_keys(self, pattern: str, prefix: str = CACHE_PREFIX):
logger.debug(f"Deleting keys by pattern: {pattern}")
if self.cache_enabled:
Expand Down
96 changes: 92 additions & 4 deletions lifemonitor/integrations/github/controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,16 @@

import logging
import os
import uuid
from tempfile import TemporaryDirectory
from typing import Callable, Dict, List, Optional, Union
from typing import Any, Callable, Dict, List, Optional, Union

import requests
from flask import Blueprint, Flask, current_app, request
from flask import (Blueprint, Flask, current_app, redirect, render_template,
request)
from flask_login import login_required
from github.PullRequest import PullRequest

from lifemonitor import cache
from lifemonitor.api import serializers
from lifemonitor.api.models import WorkflowRegistry
Expand All @@ -36,7 +41,9 @@
from lifemonitor.api.models.testsuites.testinstance import TestInstance
from lifemonitor.api.models.wizards import QuestionStep, UpdateStep
from lifemonitor.api.models.workflows import WorkflowVersion
from lifemonitor.auth.models import User
from lifemonitor.auth.oauth2.client.models import OAuthIdentity
from lifemonitor.auth.services import authorized, current_user
from lifemonitor.integrations.github import pull_requests
from lifemonitor.integrations.github.app import LifeMonitorGithubApp
from lifemonitor.integrations.github.events import (GithubEvent,
Expand All @@ -51,8 +58,6 @@
from lifemonitor.utils import (bool_from_string, get_git_repo_revision,
match_ref)

from github.PullRequest import PullRequest

from . import services

# Config a module level logger
Expand Down Expand Up @@ -723,6 +728,89 @@ def get_event_handler(event_type: str) -> Callable:
static_folder="static", static_url_path='/static')


@authorized
@blueprint.route("/integrations/github/installations/new", methods=("GET",))
def handle_registration_new():
# get a reference to the LifeMonitor Github App
gh_app = LifeMonitorGithubApp.get_instance()
# build the current state
state_id = uuid.uuid4()
# state: Dict[str, Any] = _get_state()
# save the created state on cache
# cache.cache.set(f"{state_id}", state)
# redirect to Github App registration endpoint
return redirect(f'https://github.com/apps/{gh_app.name}/installations/new?state={state_id}')


@blueprint.route("/integrations/github/installations/callback", methods=("GET",))
@login_required
def handle_registration_callback():
logger.debug(request.args)
installation_id = request.args.get('installation_id', type=str)
if not installation_id:
return "Bad request: missing parameter 'installation_id'", 400
setup_action = request.args.get('setup_action')
if not setup_action:
return "Bad request: missing parameter 'setup_action", 400
# not used yet
state = request.args.get('state', None)
logger.debug("Callback state: %r", state)

# get a reference to the LifeMonitor Github App
gh_app = LifeMonitorGithubApp.get_instance()
installation = gh_app.get_installation(int(installation_id))
assert installation, "Installation not found"
installation_repositories = {r.full_name: r.raw_data for r in installation.get_repos()}
logger.debug("Installation repositories: %r", installation_repositories)

logger.debug("Current user: %r", current_user)
assert not current_user.is_anonymous # type: ignore
user: User = current_user # type: ignore

settings = GithubUserSettings(user) \
if not user.github_settings else user.github_settings

user_installation = settings.get_installation(installation_id)
logger.debug(user_installation)
user_installation_repositories = settings.get_installation_repositories(installation_id)
logger.debug("User installation repos: %r", user_installation_repositories)

# compute list of repositories added/removed
logger.debug("Installation found on user settings")
added_repos: Dict[str, Any] = {k: v for k, v in installation_repositories.items() if k not in user_installation_repositories}
logger.debug("Added repositories: %r", added_repos)
existing_repos: Dict[str, Any] = {r: v for r, v in user_installation_repositories.items() if r in installation_repositories}
logger.debug("Existing repositories: %r", existing_repos)
removed_repos: Dict[str, Any] = {k: v for k, v in user_installation_repositories.items() if k not in installation_repositories}
logger.debug("Removed repositories: %r", removed_repos)

if setup_action == 'delete':
settings.remove_installation(installation_id)
logger.debug(f"Installation {installation_id} removed")
assert settings.get_installation(installation_id) is None, "Installation not removed"

else:
# creating or updating an installation
if not user_installation:
logger.debug(f"Installation {installation_id} not associated to the user account")
settings.add_installation(installation_id, installation.raw_data)

for r, v in added_repos.items():
settings.add_installation_repository(installation_id, r, v)
logger.debug(f"Repo {r} added to installation {installation_id}")
for r in removed_repos:
settings.remove_installation_repository(installation_id, r)
logger.debug(f"Repo {r} removed from installation {installation_id}")

# update user settings
user.save()

return render_template('github_integration/registration.j2',
installation=installation.raw_data, webapp_url=current_app.config['WEBAPP_URL'],
installation_repositories=settings.get_installation_repositories(installation_id).values(),
added_repos=added_repos.keys(), removed_repos=removed_repos.values())


@blueprint.route("/integrations/github", methods=("POST",))
def handle_event():
logger.debug("Request header keys: %r", [k for k in request.headers.keys()])
Expand Down
60 changes: 56 additions & 4 deletions lifemonitor/integrations/github/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,16 @@

from __future__ import annotations

from typing import List
import logging
from typing import Any, Dict, List

from sqlalchemy.orm.attributes import flag_modified

from lifemonitor.auth.models import User
from lifemonitor.utils import match_ref
from sqlalchemy.orm.attributes import flag_modified

# Config a module level logger
logger = logging.getLogger(__name__)


class GithubUserSettings():
Expand All @@ -36,12 +41,13 @@ class GithubUserSettings():
"all_tags": True,
"branches": ["main"],
"tags": ["v*.*.*"],
"registries": []
"registries": [],
"installations": {}
}

def __init__(self, user: User) -> None:
self.user = user
self._raw_settings = self.user.settings.get('github_settings', None)
self._raw_settings: Dict[str, Any] = self.user.settings.get('github_settings', None) # type: ignore
if not self._raw_settings:
self._raw_settings = self.DEFAULTS.copy()
self.user.settings['github_settings'] = self._raw_settings
Expand Down Expand Up @@ -117,6 +123,52 @@ def remove_tag(self, tag: str):
def is_valid_tag(self, tag: str) -> bool:
return match_ref(tag, self.tags)

@property
def installations(self) -> List[Dict[str, Any]]:
result = self._raw_settings.get('installations', None)
return list(result.values()) if result else []

@property
def _installations(self) -> Dict[str, Any]:
if "installations" not in self._raw_settings:
self._raw_settings["installations"] = {}
return self._raw_settings["installations"]

def add_installation(self, installation_id: str, info: Dict[str, Any]) -> Dict[str, Any]:
data = {
"id": installation_id,
"info": info,
"repositories": {}
}
self._installations[str(installation_id)] = data
flag_modified(self.user, 'settings')
return data

def remove_installation(self, installation_id: str):
if "installations" in self._raw_settings:
del self._raw_settings['installations'][str(installation_id)]
flag_modified(self.user, 'settings')

def get_installation(self, installation_id: str) -> Dict[str, Any]:
return self._installations.get(str(installation_id), None)

def add_installation_repository(self, installation_id: str, repo_fullname: str, repository_info: Dict[str, Any]):
inst = self.get_installation(str(installation_id))
assert inst, f"Unable to find installation '{installation_id}'"
inst['repositories'][repo_fullname] = repository_info
flag_modified(self.user, 'settings')

def remove_installation_repository(self, installation_id: str, repo_fullname: str):
inst = self.get_installation(installation_id)
assert inst, f"Unable to find installation '{installation_id}'"
del inst['repositories'][repo_fullname]
flag_modified(self.user, 'settings')

def get_installation_repositories(self, installation_id: str) -> Dict[str, Any]:
inst = self.get_installation(installation_id)
logger.debug("Installation: %r", inst)
return {k: v for k, v in inst['repositories'].items()} if inst else {}

@property
def registries(self) -> List[str]:
return self._raw_settings.get('registries', self.DEFAULTS['registries']).copy()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
{% extends 'auth/base.j2' %}
{% import 'auth/macros.j2' as macros %}

{% block body_class %} login-page {% endblock %}
{% block body_style %} height: auto; {% endblock %}

{% block body %}

<div class="mx-5" style="margin-top: 10%;">
<div class="text-center align-middle h-100 my-auto">
<div class="row h-100 my-auto align-items-center">
<div class="col-3">
<img style="width: 8em;" {% if config.get('ENV')=='production' %}
src="{{ url_for('auth.static', filename='img/logo/lm/LifeMonitorLogo.png') }}" {% else %}
src="{{ url_for('auth.static', filename='img/logo/lm/LifeMonitorLogo-dev.png') }}" {% endif %}
alt="LifeMonitor Logo" class="" />
</div>
<div class="col-2">
---------
</div>
<div class="col-2 text-center" style="width: 100%;">
<img src="{{ installation.account.avatar_url }}" style="width: 60px;">
<div class="text-center" style="text-align: center;">

<div style="font-size: 0.6em; margin-bottom: -5px;">GitHub Account</div>
<div>
<a class="mt-1 p-0" style="font-weight: bold;" href="{{ installation.account.html_url }}" target="_blank">
{{ installation.account.login }}
</a>
</div>
</div>
</div>
<div class="col-2">
-----------
</div>
<div class="col-3">
<img style="width: 9em;" src="{{ url_for('auth.static', filename='img/logo/providers/github.png') }}" />
</div>
</div>
</div>
</div>

<!-- Main content -->
<div class="container-sm">
<div class="card card-primary card-outline mx-5 my-4 p-5">
<h3 class="text-center p-0 m-0" style="color: #133233">
<span
style="font-style: italic; font-family: Baskerville,Baskerville Old Face,Hoefler Text,Garamond,Times New Roman,serif;">Life</span>
<span class="small" style="font-size: 75%; margin: 0 -5px 0 -5px;">-</span>
<span style="font-weight: bold; font-family: Gill Sans,Gill Sans MT,Calibri,sans-serif;">Monitor</span>
GitHub App installed!<br />
</h3>

<!-- added repositories -->
{% if installation_repositories %}
<h5 class="mt-5 text-bold">Grant access to:</h5>

<table class="table table-striped">
<thead>
<tr>
<th style="width: 5px"></th>
<th>Repository</th>
<th style="width: 100px"></th>
<th style="width: 120px"></th>
</tr>
</thead>
<tbody>
{% for r in installation_repositories %}
<tr>
<td><i class="fab fa-github mr-1"></i></td>
<td>
{% if r.full_name in added_repos %}
<span class="font-italic text-bold">{{r.full_name}}</span>
<span style="font-variant: small-caps;">[new]</span>
{% else %}
<span>
{{r.full_name}}
</span>
{% endif %}
</td>
<td></td>
<td>
<a href="{{r.html_url}}" target="_blank">
<i class="far fa-folder-open mx-2"></i>
</a>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}


<!-- removed repositories -->
{% if removed_repos %}
<h5 class="mt-5 text-bold">Revoked access to:</h5>

<table class="table table-striped">
<thead>
<tr>
<th style="width: 5px"></th>
<th>Repository</th>
<th style="width: 100px"></th>
<th style="width: 120px"></th>
</tr>
</thead>
<tbody>
{% for r in removed_repos %}
<tr>
<td><i class="fab fa-github mr-1"></i></td>
<td>{{r.full_name}}</td>
<td></td>
<td>
<a href="{{r.html_url}}" target="_blank">
<i class="far fa-folder-open mx-2"></i>
</a>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}

<div class="container-sm text-center p-4 mt-5">
<div class="form-group pt-5 text-center d-inline">
<a type="button" href="{{webapp_url}}" target="_blank"
class="btn btn-primary text-bold" style="width: 175px">
Dashboard
</a>
</div>

<div class="form-group pt-5 text-center d-inline">
<a type="button" class="btn btn-primary text-bold" style="width: 175px"
href="/profile?currentView=githubSettingsTab" target="_blank">Settings</a>
</div>

<div class="form-group pt-5 text-center d-inline">
<a type="button" href="https://github.com/settings/installations/{{installation.id}}"
class="btn btn-primary text-bold" style="width: 175px" target="_blank">Manage</a>
</div>
</div>
</div>
</div>



{% endblock body %}
Loading