Commit 588fc83c authored by Mark Ghiorso's avatar Mark Ghiorso

Conversion to GitLab access extension

(1) Converted to GitLab API v4 protocols
(2) Added input text widgets for GitLab private keys and groups
(3) Have not implemented functionality for cloning, branching or uploading files to repositories
(4) Template for this code is the GitHub access extension provided by the JupyterLab developer’s group
parent 3593a24b
# jupyterlab_gitlab
# Jupyterlab GitLab
A JupyterLab extension to access GitLab repositories
A JupyterLab extension for accessing GitLab repositories.
### What this extension is
When you install this extension, an additional filebrowser tab will be added
to the left area of JupyterLab. This filebrowser allows you to select GitLab
repositories belonging to GitLab users and/or groups, browse their repositories,
and open the files in those
repositories. If those files are notebooks, you can run them just as you would
any other notebook. You can also attach a kernel to text files and run those.
Basically, you should be able to open any file in a repository that JupyterLab can handle.
### What this extension is not
This is not an extension that provides full GitLab access, such as
saving files, making commits, forking repositories, etc. Many of these features will
be added in a subsequent release.
## Prerequisites
* JupyterLab
* JupyterLab 0.29
* A GitLab account for accessing non-public repositories
## Installation
### 1. Installing the labextension
To install the labextension, enter the following in your terminal:
```bash
jupyter labextension install jupyterlab_gitlab
jupyter labextension install .
```
### 2. Getting your private key credentials from GitLab
You will need to generate a private key on Gitlab to authorize access of non-public repositories.
1. Log into your GitLab account.
1. Go to your account settings.
1. Choose "Access Tokens" from the "User Settings" menu panel.
1. Provide a token name, expiration dtate (if desired) and check the "api" access seting.
1. Click the "Create personal access token" button.
1. The page will reload. Record the generated access token at the top of the page. Remember this value and keep it secret.
It is important to note that the "private key" string is, as the name suggests, a secret.
*Do not* share this value online, as people may be able to use it to impersonate you on GitLab.
### 3. Using the extension on JupyterLab
After you click on the extension tab (left side of the JupyterLab browser window), fill in the "User Name" text box with your GitLab user name. Fill in the "User Code" text box with your private key (see above), and optionally fill in the "Group Name" text box with a group of projects that you would like to access (e.g., "ENKI-portal"). Note that if you fill in the "Group Name" text box, its contents take precedent over your "User Name" in the reposiotory listing.
## Development
For a development install (requires npm version 4 or later), do the following in the repository directory:
......@@ -28,4 +64,3 @@ To rebuild the package and the JupyterLab app:
npm run build
jupyter lab build
```
......@@ -95,6 +95,24 @@
"url-parse": "1.1.9"
}
},
"@jupyterlab/docmanager": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@jupyterlab/docmanager/-/docmanager-0.12.0.tgz",
"integrity": "sha1-giSMOXPRdLxuZEBjDaWYY+shhFc=",
"requires": {
"@jupyterlab/apputils": "0.12.4",
"@jupyterlab/coreutils": "0.12.0",
"@jupyterlab/docregistry": "0.12.0",
"@jupyterlab/services": "0.51.0",
"@phosphor/algorithm": "1.1.2",
"@phosphor/coreutils": "1.3.0",
"@phosphor/disposable": "1.1.2",
"@phosphor/messaging": "1.2.2",
"@phosphor/properties": "1.1.2",
"@phosphor/signaling": "1.2.2",
"@phosphor/widgets": "1.5.0"
}
},
"@jupyterlab/docregistry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@jupyterlab/docregistry/-/docregistry-0.12.0.tgz",
......@@ -114,6 +132,27 @@
"@phosphor/widgets": "1.5.0"
}
},
"@jupyterlab/filebrowser": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/@jupyterlab/filebrowser/-/filebrowser-0.12.1.tgz",
"integrity": "sha512-+lBSRXQFr3AW/dIGF9E/qD8FaS6Rn7lBzu/cylOui6X1av7oBNShYiT7mDWYMQoJlDMKRUXtpLIc1C/a+kX26Q==",
"requires": {
"@jupyterlab/apputils": "0.12.4",
"@jupyterlab/coreutils": "0.12.0",
"@jupyterlab/docmanager": "0.12.0",
"@jupyterlab/docregistry": "0.12.0",
"@jupyterlab/services": "0.51.0",
"@phosphor/algorithm": "1.1.2",
"@phosphor/commands": "1.4.0",
"@phosphor/coreutils": "1.3.0",
"@phosphor/disposable": "1.1.2",
"@phosphor/domutils": "1.1.2",
"@phosphor/dragdrop": "1.3.0",
"@phosphor/messaging": "1.2.2",
"@phosphor/signaling": "1.2.2",
"@phosphor/widgets": "1.5.0"
}
},
"@jupyterlab/rendermime": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@jupyterlab/rendermime/-/rendermime-0.12.0.tgz",
......
......@@ -22,7 +22,9 @@
"extension": true
},
"dependencies": {
"@jupyterlab/application": "^0.12.0"
"@jupyterlab/application": "^0.12.0",
"@jupyterlab/docmanager": "^0.12.0",
"@jupyterlab/filebrowser": "^0.12.0"
},
"devDependencies": {
"rimraf": "^2.6.1",
......
"""
Setup module for the jupyterlab-gitlab proxy extension
"""
import setuptools
setuptools.setup(
name='jupyterlab_gitlab',
description='A Jupyter Notebook server extension which acts as a proxy for GitLab API requests.',
version='0.1.0',
packages=setuptools.find_packages(),
author = 'Mark S. Ghiorso, based on Jupyter Development Team. GitHub',
author_email = 'ghiorso@ofm-research.org',
url = 'http://enki-portal.org',
license = 'BSD',
platforms = "Linux, Mac OS X, Windows",
keywords = ['Jupyter', 'JupyterLab', 'GitLab'],
classifiers = [
'Intended Audience :: Developers',
'Intended Audience :: System Administrators',
'Intended Audience :: Science/Research',
'License :: OSI Approved :: BSD License',
'Programming Language :: Python',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
],
install_requires=[
'notebook'
],
package_data={'jupyterlab_gitlab':['*']},
)
import {
PanelLayout, Widget
} from '@phosphor/widgets';
import {
ToolbarButton
} from '@jupyterlab/apputils';
import {
ObservableValue, URLExt
} from '@jupyterlab/coreutils';
import {
FileBrowser
} from '@jupyterlab/filebrowser';
import {
GitLabDrive, parsePath
} from './contents';
/**
* The GitLab base url.
*/
const GITLab_BASE_URL = 'https://gitLab.com';
/**
* Widget for hosting the GitLab filebrowser.
*/
export
class GitLabFileBrowser extends Widget {
static user_name: string;
static private_key: string;
static group_name: string;
constructor(browser: FileBrowser, drive: GitLabDrive) {
super();
//console.warn('Entering constructor for GitLabFileBrowser');
this.addClass('jp-GitLabBrowser');
this.layout = new PanelLayout();
(this.layout as PanelLayout).addWidget(browser);
this._browser = browser;
this._drive = drive;
// Create an editable name for the user name.
this.userName = new GitLabEditableName('', '<Edit User>');
this.userName.addClass('jp-GitLabEditableUserName');
this.userName.node.title = 'Click to edit user';
this._browser.toolbar.addItem('user', this.userName);
this.userName.name.changed.connect(this._onUserChanged, this);
// Create an editable name for the user/org access code.
this.userCode = new GitLabEditableName('', '<Priv Code>');
this.userCode.addClass('jp-GitLabEditableUserCode');
this.userCode.node.title = 'Click to edit access code';
this._browser.toolbar.addItem('code', this.userCode);
this.userCode.name.changed.connect(this._onCodeChanged, this);
// Create an editable name for the group name.
this.groupName = new GitLabEditableName('', '<Edit Group>');
this.groupName.addClass('jp-GitLabEditableGroupName');
this.groupName.node.title = 'Click to edit group';
this._browser.toolbar.addItem('group', this.groupName);
this.groupName.name.changed.connect(this._onGroupChanged, this);
// Create a button that opens GitLab at the appropriate repo+directory.
this._openGitLabButton = new ToolbarButton({
onClick: () => {
let url = GITLab_BASE_URL;
// If there is no valid user, open the GitLab homepage.
if (!this._drive.validUser) {
window.open(url);
return;
}
//console.warn('... open: ' + this._browser.model.path.split(':')[1]);
const resource = parsePath(this._browser.model.path.split(':')[1]);
url = URLExt.join(url, resource.user);
if (resource.repository) {
url = URLExt.join(url, resource.repository,
'tree', 'master', resource.path);
}
window.open(url);
},
className: 'jp-GitLabIcon',
tooltip: 'Open this repository on GitLab'
});
this._browser.toolbar.addItem('GitLab', this._openGitLabButton);
}
/**
* An editable widget hosting the current user name.
*/
readonly userName: GitLabEditableName;
/**
* An editable widget hosting the current user private acceess code.
*/
readonly userCode: GitLabEditableName;
/**
* An editable widget hosting the current group name.
*/
readonly groupName: GitLabEditableName;
/**
* React to a change in user.
*/
private _onUserChanged(sender: ObservableValue, args: ObservableValue.IChangedArgs) {
GitLabFileBrowser.user_name = String(sender.get());
if (this._changeGuard) {
return;
}
this._changeGuard = true;
this._browser.model.cd(`/${args.newValue as string}`).then(() => {
this._changeGuard = false;
this._updateErrorPanel();
// Once we have the new listing, maybe give the file listing
// focus. Once the input element is removed, the active element
// appears to revert to document.body. If the user has subsequently
// focused another element, don't focus the browser listing.
if (document.activeElement === document.body) {
const listing = (this._browser.layout as PanelLayout).widgets[2];
listing.node.focus();
}
});
}
/**
* React to a change in code.
*/
private _onCodeChanged(sender: ObservableValue, args: ObservableValue.IChangedArgs) {
//console.warn('_onCodeChanged called = ' + sender.get());
GitLabFileBrowser.private_key = String(sender.get());
}
/**
* React to a change in group.
*/
private _onGroupChanged(sender: ObservableValue, args: ObservableValue.IChangedArgs) {
GitLabFileBrowser.group_name = String(sender.get());
}
/**
* React to a change in the validity of the drive.
*/
private _updateErrorPanel(): void {
const resource = parsePath(this._browser.model.path.split(':')[1]);
const validUser = this._drive.validUser;
// If we currently have an error panel, remove it.
if (this._errorPanel) {
const listing = (this._browser.layout as PanelLayout).widgets[2];
listing.node.removeChild(this._errorPanel.node);
this._errorPanel.dispose();
this._errorPanel = null;
}
// If we have an invalid user, make an error panel.
if (!validUser) {
const message = resource.user ?
`"${resource.user}" appears to be an invalid user name!` :
'Please enter a GitLab user name';
this._errorPanel = new GitLabErrorPanel(message);
const listing = (this._browser.layout as PanelLayout).widgets[2];
listing.node.appendChild(this._errorPanel.node);
}
}
private _browser: FileBrowser;
private _drive: GitLabDrive;
private _errorPanel: GitLabErrorPanel | null;
private _openGitLabButton: ToolbarButton;
private _changeGuard = false;
}
/**
* A widget that hosts an editable field, which holds the currently active GitLab user name.
*/
export
class GitLabEditableName extends Widget {
constructor(initialName: string = '', placeholder?: string) {
super();
this.addClass('jp-GitLabEditableName');
this._nameNode = document.createElement('div');
this._nameNode.className = 'jp-GitLabEditableName-display';
this._editNode = document.createElement('input');
this._editNode.className = 'jp-GitLabEditableName-input';
this._placeholder = placeholder || '<Edit Name>'
this.node.appendChild(this._nameNode);
this.name = new ObservableValue(initialName);
this._nameNode.textContent = initialName || this._placeholder;
this.node.onclick = () => {
if (this._pending) {
return;
}
this._pending = true;
Private.changeField(this._nameNode, this._editNode).then(value => {
this._pending = false;
if (this.name.get() !== value) {
this.name.set(value);
}
});
};
this.name.changed.connect((s, args) => {
if (args.oldValue !== args.newValue) {
this._nameNode.textContent =
args.newValue as string || this._placeholder;
}
});
}
/**
* The current name of the field.
*/
readonly name: ObservableValue;
private _pending = false;
private _placeholder: string;
private _nameNode: HTMLElement;
private _editNode: HTMLInputElement;
}
/**
* A widget hosting an error panel for the browser,
* used if there is an invalid user name or if we
* are being rate-limited.
*/
export
class GitLabErrorPanel extends Widget {
constructor(message: string) {
super();
this.addClass('jp-GitLabErrorPanel');
const image = document.createElement('div');
const text = document.createElement('div');
image.className = 'jp-GitLabErrorImage';
text.className = 'jp-GitLabErrorText';
text.textContent = message;
this.node.appendChild(image);
this.node.appendChild(text);
}
}
/**
* A module-Private namespace.
*/
namespace Private {
export
/**
* Given a text node and an input element, replace the text
* node wiht the input element, allowing the user to reset the
* value of the text node.
*
* @param text - The node to make editable.
*
* @param edit - The input element to replace it with.
*
* @returns a Promise that resolves when the editing is complete,
* or has been canceled.
*/
function changeField(text: HTMLElement, edit: HTMLInputElement): Promise<string> {
// Replace the text node with an the input element.
let parent = text.parentElement as HTMLElement;
let initialValue = text.textContent;
edit.value = initialValue;
parent.replaceChild(edit, text);
edit.focus();
// Highlight the input element
let index = edit.value.lastIndexOf('.');
if (index === -1) {
edit.setSelectionRange(0, edit.value.length);
} else {
edit.setSelectionRange(0, index);
}
return new Promise<string>((resolve, reject) => {
edit.onblur = () => {
// Set the text content of the original node, then
// replace the node.
parent.replaceChild(text, edit);
text.textContent = edit.value || initialValue;
resolve(edit.value);
};
edit.onkeydown = (event: KeyboardEvent) => {
switch (event.keyCode) {
case 13: // Enter
event.stopPropagation();
event.preventDefault();
edit.blur();
break;
case 27: // Escape
event.stopPropagation();
event.preventDefault();
edit.value = initialValue;
edit.blur();
break;
default:
break;
}
};
});
}
}
This diff is collapsed.
import {
URLExt
} from '@jupyterlab/coreutils';
import {
ServerConnection
} from '@jupyterlab/services';
import {
GitLabFileBrowser
} from './browser';
export
const GITLab_API = 'https://gitlab.com/api/v4';
/**
* Make a client-side request to the GitLib API.
*
* @param url - the api path for the GitLib API v4
* (not including the base url).
*
* @returns a Promise resolved with the JSON response.
*/
export
function browserApiRequest<T>(url: string): Promise<T> {
let key = '';
if (GitLabFileBrowser.private_key !== '') {
if (url.indexOf("?") === -1) {
key = '?private_token=' + GitLabFileBrowser.private_key;
} else {
key = '&private_token=' + GitLabFileBrowser.private_key;
}
}
return new Promise((resolve, reject) => {
const method = 'GET';
const requestUrl = URLExt.join(GITLab_API, url) + key;
let xhr = new XMLHttpRequest();
xhr.open(method, requestUrl);
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.response));
} else {
const err: ServerConnection.IError = {
xhr,
settings: undefined,
request: undefined,
event: undefined,
message: xhr.responseText
};
reject(err);
}
};
xhr.onerror = () => {
const err: ServerConnection.IError = {
xhr,
settings: undefined,
request: undefined,
event: undefined,
message: xhr.responseText
};
reject(err);
};
xhr.send();
});
}
/**
* Typings representing contents from the GitLab API v4.
*/
export
class GitLabContents {
type: 'file' | 'dir' | 'submodule' | 'symlink' | 'tree' | 'blob';
id: string;
name: string;
mode: string;
path: string;
}
/**
* Typings representing file contents from the GitLab API v4.
*/
export
class GitLabFileContents extends GitLabContents {
type: 'file';
encoding: 'base64';
content?: string;
}
/**
* Typings representing a directory from the GitLab API v4.
*/
export
class GitLabDirectoryContents extends GitLabContents {
type: 'dir';
}
/**
* Typings representing a blob from the GitLab API v4.
*/
export
class GitLabBlob {
content: string;
encoding: 'base64';
url: string;
sha: string;
size: number;
}
/**
* Typings representing symlink contents from the GitLab API v4.
*/
export
class GitLabSymlinkContents extends GitLabContents {
type: 'symlink';
}
/**
* Typings representing submodule contents from the GitLab API v4.
*/
export
class GitLabSubmoduleContents extends GitLabContents {
type: 'submodule';
}
/**
* Typings representing directory contents from the GitLab API v4.
*/
export
type GitLabDirectoryListing = GitLabContents[];
/**
* Typings representing repositories from the GitLab API v4.
*
* #### Notes
* This is incomplete.
*/
export
class GitLabRepo {
id: number;
owner: any;
name: string;
name_with_namespace: string;
description: string;
web_url: string;
}
import {
JupyterLab, JupyterLabPlugin, ILayoutRestorer
ILayoutRestorer, JupyterLab, JupyterLabPlugin
} from '@jupyterlab/application';
import {
ICommandPalette, InstanceTracker
} from '@jupyterlab/apputils';
IStateDB
} from '@jupyterlab/coreutils';
import {
Widget
} from '@phosphor/widgets';
IDocumentManager
} from '@jupyterlab/docmanager';
import {
Message
} from '@phosphor/messaging';
IFileBrowserFactory
} from '@jupyterlab/filebrowser';
import {
JSONExt
} from '@phosphor/coreutils';
GitLabDrive
} from './contents';
import {
ServerConnection
} from '@jupyterlab/services';
GitLabFileBrowser
} from './browser';
import '../style/index.css';
/**
* An xckd comic viewer.
* GitLab filebrowser plugin state namespace.
*/
class XkcdWidget extends Widget {
/**
* Construct a new xkcd widget.
*/
constructor() {
super();
this.settings = ServerConnection.makeSettings();
this.id = 'xkcd-jupyterlab';
this.title.label = 'xkcd.com';
this.title.closable = true;
this.addClass('jp-xkcdWidget');
this.img = document.createElement('iframe');
this.img.className = 'jp-xkcdCartoon';
this.node.appendChild(this.img);
this.img.insertAdjacentHTML('afterend',
`<div class="jp-xkcdAttribution">
<a href="https://creativecommons.org/licenses/by-nc/2.5/" class="jp-xkcdAttribution" target="_blank">
<img src="https://licensebuttons.net/l/by-nc/2.5/80x15.png" />
</a>
</div>`
);
}
/**
* The server settings associated with the widget.
*/
readonly settings: ServerConnection.ISettings;
/**
* The image element associated with the widget.
*/
//readonly img: HTMLImageElement; - original
readonly img: HTMLIFrameElement;
/**
* Handle update requests for the widget.
*/
onUpdateRequest(msg: Message): void {
/*
ServerConnection.makeRequest({url: 'https://egszlpbmle.execute-api.us-east-1.amazonaws.com/prod'}, this.settings).then(response => {
this.img.src = response.data.img;
this.img.alt = response.data.title;
this.img.title = response.data.alt;
});
*/
//ServerConnection.makeRequest({url: 'https://www.youtube.com/embed/R7ZhuhOhu38'}, this.settings).then(response => {
this.img.src = "https://www.youtube.com/embed/R7ZhuhOhu38";
this.img.width = "800";
this.img.height = "450";
this.img.frameBorder = "0";
this.img.allowFullscreen = true;
//});
}
const NAMESPACE = 'gitLab-filebrowser';
/**
* The JupyterLab plugin for the GitLab Filebrowser.
*/
const fileBrowserPlugin: JupyterLabPlugin<void> = {
id: 'jupyterlab-gitLab:drive',
requires: [IDocumentManager, IFileBrowserFactory, ILayoutRestorer, IStateDB],
activate: activateFileBrowser,
autoStart: true
};
/**
* Activate the xckd widget extension.
* Activate the file browser.
*/
function activate(app: JupyterLab, palette: ICommandPalette, restorer: ILayoutRestorer) {
console.log('JupyterLab extension jupyterlab_xkcd is activated!');
// Declare a widget variable
let widget: XkcdWidget;
// Add an application command
const command: string = 'xkcd:open';
app.commands.addCommand(command, {
label: 'Random xkcd comic',
execute: () => {
if (!widget) {
// Create a new widget if one does not exist