'
40 | ```
41 |
42 |
43 | After installation, start JupyterLab normally & you should see "Git-Plus" as a new menu item at the top.
44 |
45 | #### 3. [OPTIONAL] Only required for self-hosted ReviewNB customers
46 |
47 | If your organization has a self-hosted ReviewNB instance running at e.g. `https://reviewnb.yourdomain.com`. You can configure
48 | GitPlus to link users to PRs/commits at this URL instead of the default one as follows:
49 |
50 | ```python
51 | c.GitPlus.self_hosted_reviewnb_endpoint = "https://reviewnb.yourdomain.com/"
52 | ```
53 |
54 | ## FAQ
55 |
56 | Where is pull request (PR) opened in case of forked repositories?
57 |
58 |
59 | If your repository is forked from another repository (parent) then PR will be created on parent repository.
60 |
61 |
62 |
63 | Which is the base branch used in a pull request?
64 |
65 |
66 | `base` branch in a PR is a branch against which your changes are compared and ultimately merged. We use repository's default branch (usually called `master`) as `base` branch of PR. We use parent repository's default branch as `base` in case of forked repo.
67 |
68 |
69 |
70 | Which is the head branch used in a pull request?
71 |
72 |
73 | `head` branch in a PR is a branch which contains the latest changes you've made. We create a new branch (e.g. `gitplus-xyz123`) as `head` branch. It only contains changes from the files you wish to include in the PR.
74 |
75 |
76 |
77 | How can I edit a pull request opened with GitPlus?
78 |
79 |
80 | You can head over to GitHub and edit the PR metadata to your liking. For pushing additional file changes to the same PR,
81 | 1. Copy the branch name from GitHub UI (e.g. `gitplus-xyz123`)
82 | 2. Checkout that branch locally
83 | 3. Make the file changes you want
84 | 4. Use push commit functionality from GitPlus to push new changes
85 |
86 |
87 |
88 | Is GitPlus tied to ReviewNB in any way?
89 |
90 |
91 | No. GitPlus is it's own open source project. The only connection with ReviewNB is that at the end of PR/Commit creation, GitPlus shows ReviewNB URL along with GitHub URL. You can safely ignore these URLs if you don't want to use ReviewNB.
92 |
93 | It's is useful to see [visual notebook diffs](https://uploads-ssl.webflow.com/5ba4ebe021cb91ae35dbf88c/5ba93ded243329a486dab26e_sl-code%2Bimage.png) on ReviewNB instead of hard to read [JSON diffs](https://uploads-ssl.webflow.com/5ba4ebe021cb91ae35dbf88c/5c24ba833c78e57d6b8c9d09_Screenshot%202018-12-27%20at%204.43.09%20PM.png) on GitHub. [ReviewNB](https://www.reviewnb.com/) also facilitates discussion on notebooks cells.
94 |
95 |
96 |
97 | What if I don't have a ReviewNB account?
98 |
99 |
100 | No problem, everything in GitPlus will still work fine. Only the ReviewNB URLs won't work for you.
101 |
102 |
103 |
104 |
105 | Can we use GitPlus with Gitlab/BitBucket or any other platforms?
106 |
107 |
108 | No, currently we only support repositories on GitHub.
109 |
110 |
111 | ## Motivation
112 | Our aim is to make notebooks a first class entity in Data science & ML teams. We can achieve this by making notebooks play well with existing tools & processes instead of building expensive proprietary platforms. Other projects in this direction are,
113 |
114 | - [ReviewNB](https://www.reviewnb.com/) - Code review tool for Jupyter notebooks
115 | - [treon](https://github.com/reviewnb/treon) - Easy to use test framework for Jupyter notebooks
116 |
117 | ## Roadmap
118 | In future GitPlus will be able to,
119 |
120 | - Pull changes from GitHub
121 | - Switch/create branches locally
122 | - Resolve notebook merge conflicts (without messing with underlying JSON)
123 |
124 |
125 | ## Development
126 |
127 | ### Install
128 |
129 | The `jlpm` command is JupyterLab's pinned version of
130 | [yarn](https://yarnpkg.com/) that is installed with JupyterLab. You may use
131 | `yarn` or `npm` in lieu of `jlpm` below.
132 |
133 | ```bash
134 | # Clone the repo to your local environment & install dependencies
135 |
136 | # via https://jupyterlab.readthedocs.io/en/stable/extension/extension_tutorial.html#build-and-install-the-extension-for-development
137 | pip install -ve .
138 |
139 | # Link your development version of the extension with JupyterLab
140 | jupyter labextension link .
141 |
142 | # Run jupyterlab in watch mode in one terminal tab
143 | jupyter lab --watch
144 |
145 | # Watch the GitPlus source directory in another terminal tab
146 | jlpm watch
147 |
148 | # If you make any changes to server side extension (.py files) then reinstall it from source
149 | pip install .
150 | ```
151 |
152 | ## Contributing
153 | If you see any problem, open an issue or send a pull request. You can write to support@reviewnb.com for any questions.
154 |
--------------------------------------------------------------------------------
/images/Commit_thumbnail_v1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ReviewNB/jupyterlab-gitplus/07055df3df41bea19ac7769f5fa6972e67de927e/images/Commit_thumbnail_v1.png
--------------------------------------------------------------------------------
/images/PR_thumbnail_v2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ReviewNB/jupyterlab-gitplus/07055df3df41bea19ac7769f5fa6972e67de927e/images/PR_thumbnail_v2.png
--------------------------------------------------------------------------------
/jupyter-config/nb-config/jupyterlab_gitplus.json:
--------------------------------------------------------------------------------
1 | {
2 | "NotebookApp": {
3 | "nbserver_extensions": {
4 | "jupyterlab_gitplus": true
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/jupyter-config/server-config/jupyterlab_gitplus.json:
--------------------------------------------------------------------------------
1 | {
2 | "ServerApp": {
3 | "jpserver_extensions": {
4 | "jupyterlab_gitplus": true
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/jupyterlab_gitplus/__init__.py:
--------------------------------------------------------------------------------
1 | import json
2 | from pathlib import Path
3 | from urllib.parse import urlparse
4 |
5 | from notebook.utils import url_path_join
6 | from .handlers import ModifiedRepositoryListHandler, PullRequestHandler, CommitHandler, ServerConfigHandler
7 |
8 | HERE = Path(__file__).parent.resolve()
9 |
10 | with (HERE / "labextension" / "package.json").open() as fid:
11 | data = json.load(fid)
12 |
13 | def _jupyter_labextension_paths():
14 | return [{
15 | "src": "labextension",
16 | "dest": data["name"]
17 | }]
18 |
19 |
20 | def _jupyter_server_extension_paths():
21 | return [{
22 | "module": "jupyterlab_gitplus"
23 | }]
24 |
25 |
26 | __version__ = "0.3.4"
27 |
28 |
29 | def _load_jupyter_server_extension(nb_server_app):
30 | """Registers the API handler to receive HTTP requests from the frontend extension.
31 |
32 | Parameters
33 | ----------
34 | nb_server_app: jupyterlab.labapp.LabApp
35 | JupyterLab application instance
36 | """
37 | web_app = nb_server_app.web_app
38 | base_url = web_app.settings['base_url']
39 | server_root_dir = web_app.settings['server_root_dir']
40 | host_pattern = '.*$'
41 |
42 | gh_token = nb_server_app.config.get('GitPlus', {}).get('github_token', '')
43 | reviewnb_endpoint = nb_server_app.config.get('GitPlus', {}).get('self_hosted_reviewnb_endpoint', 'https://app.reviewnb.com/')
44 |
45 | try:
46 | x = urlparse(reviewnb_endpoint)
47 |
48 | if x.path not in ["", "/"]:
49 | raise ValueError(f"reviewnb_endpoint URL must not have a path component. Probably you should change it to https://{x.netloc}/")
50 |
51 | if not reviewnb_endpoint.endswith("/"):
52 | reviewnb_endpoint += "/"
53 | except ValueError:
54 | nb_server_app.log.error(f"Unable to parse config c.GitPlus.self_hosted_reviewnb_endpoint={reviewnb_endpoint}")
55 | raise
56 |
57 | context = {'github_token': gh_token, 'server_root_dir': server_root_dir, 'reviewnb_endpoint': reviewnb_endpoint}
58 |
59 | web_app.add_handlers(host_pattern, [(url_path_join(base_url, 'gitplus/expanded_server_root'), ServerConfigHandler, {"context": context})])
60 | web_app.add_handlers(host_pattern, [(url_path_join(base_url, 'gitplus/modified_repo'), ModifiedRepositoryListHandler, {"context": context})])
61 | web_app.add_handlers(host_pattern, [(url_path_join(base_url, 'gitplus/pull_request'), PullRequestHandler, {"context": context})])
62 | web_app.add_handlers(host_pattern, [(url_path_join(base_url, 'gitplus/commit'), CommitHandler, {"context": context})])
63 | nb_server_app.log.info(f"Registered GitPlus extension at URL path /jupyterlab-gitplus with reviewnb_endpoint={reviewnb_endpoint}")
64 |
65 |
66 | # For backward compatibility with notebook server - useful for Binder/JupyterHub
67 | load_jupyter_server_extension = _load_jupyter_server_extension
68 |
--------------------------------------------------------------------------------
/jupyterlab_gitplus/github_v3.py:
--------------------------------------------------------------------------------
1 | import json
2 | from .requests import retriable_requests
3 |
4 |
5 | import traceback
6 | import logging
7 | logger = logging.getLogger(__name__)
8 |
9 |
10 | GITHUB_REST_ENDPOINT = 'https://api.github.com/'
11 |
12 |
13 | def create_pull_request(owner_login, repo_name, title, head, base, access_token, reviewnb_endpoint):
14 | content = {}
15 | url = GITHUB_REST_ENDPOINT + 'repos/' + owner_login + '/' + repo_name + '/pulls'
16 | headers = {
17 | 'Authorization': 'token ' + access_token
18 | }
19 | data = {
20 | 'title': title,
21 | 'head': head,
22 | 'base': base
23 | }
24 |
25 | try:
26 | response = retriable_requests().post(url, headers=headers, json=data)
27 | content = json.loads(response.content)
28 | response.raise_for_status()
29 | result = {
30 | 'github_url': content['html_url'],
31 | 'reviewnb_url': content['html_url'].replace('https://github.com/', reviewnb_endpoint)
32 | }
33 | return result
34 | except Exception as ex:
35 | logger.error('Request payload: ' + str(data))
36 | logger.error('API Response: ' + str(content))
37 | logger.error(traceback.format_exc())
38 | raise ex
39 |
40 |
41 | def get_repository_details_for_pr(owner_login, repo_name, access_token, new_branch_name):
42 | '''
43 | Checks if the repository is forked from another repository.
44 | If yes, returns (parent_owner_login, parent_repo_name, owner_login:new_branch_name, parent_default_branch)
45 | If no, returns (owner_login, repo_name, new_branch_name, default_branch) of the repo passed in the argument.
46 | '''
47 | repo = get_repository(owner_login, repo_name, access_token)
48 | if repo['fork']:
49 | # passed in repo is a fork
50 | head = owner_login + ':' + new_branch_name
51 | return repo['parent']['owner']['login'], repo['parent']['name'], head, repo['parent']['default_branch']
52 | else:
53 | return owner_login, repo_name, new_branch_name, repo['default_branch']
54 |
55 |
56 | def get_repository(owner_login, repo_name, access_token):
57 | content = {}
58 | url = GITHUB_REST_ENDPOINT + 'repos/' + owner_login + '/' + repo_name
59 | headers = {
60 | 'Authorization': 'token ' + access_token
61 | }
62 | try:
63 | response = retriable_requests().get(url, headers=headers)
64 | content = json.loads(response.content)
65 | response.raise_for_status()
66 | return content
67 | except Exception as ex:
68 | logger.error('get_repository url: ' + str(url))
69 | logger.error('get_repository API Response: ' + str(content))
70 | logger.error(traceback.format_exc())
71 | raise(ex)
72 |
--------------------------------------------------------------------------------
/jupyterlab_gitplus/handlers.py:
--------------------------------------------------------------------------------
1 | import json
2 | import git
3 | import os
4 | import random
5 | import string
6 |
7 | from notebook.base.handlers import IPythonHandler
8 | from git import Repo
9 | from shutil import copyfile, rmtree
10 | from .github_v3 import create_pull_request, get_repository_details_for_pr
11 | from .utils import get_owner_login_and_repo_name, onerror
12 |
13 |
14 | import traceback
15 | import logging
16 | logger = logging.getLogger(__name__)
17 |
18 |
19 | GITHUB_ENDPOINT = 'https://github.com/'
20 | GITHUB_REMOTE_DOMAIN = 'github.com'
21 |
22 |
23 | class ModifiedRepositoryListHandler(IPythonHandler):
24 | """
25 | Given a list of recently opened files we return repositories to which these files belong to (if the file is under a git repository)
26 | """
27 | def initialize(self, context):
28 | pass
29 |
30 | def post(self):
31 | body = {}
32 | try:
33 | body = json.loads(self.request.body)
34 | body = body['files']
35 | repositories = []
36 | unique_paths = set()
37 | response = []
38 |
39 | for file in body:
40 | try:
41 | repo = Repo(file['path'], search_parent_directories=True)
42 |
43 | if GITHUB_REMOTE_DOMAIN not in repo.remotes.origin.url:
44 | logger.info('File is not a part of GitHub repository: ' + file['path'])
45 | elif repo.working_dir not in unique_paths:
46 | unique_paths.add(repo.working_dir)
47 | repositories.append(repo)
48 | except git.exc.NoSuchPathError:
49 | logger.info('File not found: ' + file['path'])
50 | except git.exc.InvalidGitRepositoryError:
51 | logger.info('File is not under Git repository: ' + file['path'])
52 |
53 | for repo in repositories:
54 | path = repo.working_dir.replace(os.sep, '/')
55 | name = os.path.basename(path)
56 | response.append({
57 | "path": path,
58 | "name": name
59 | })
60 |
61 | self.finish(json.dumps(response))
62 | except Exception as ex:
63 | logger.error('gitplus/modified_repo request payload: ' + str(body))
64 | logger.error(traceback.format_exc())
65 | raise(ex)
66 |
67 |
68 | class PullRequestHandler(IPythonHandler):
69 | def initialize(self, context):
70 | self.github_token = context["github_token"]
71 | self.reviewnb_endpoint = context["reviewnb_endpoint"]
72 |
73 | def post(self):
74 | """
75 | Create a pull request
76 | """
77 | body = {}
78 | try:
79 | body = json.loads(self.request.body)
80 | file_paths = body['files']
81 | repo_path = body['repo_path']
82 | commit_msg = body['commit_message']
83 | pr_title = body['pr_title']
84 | temp_repo_path = "/tmp/temp_repo.git"
85 | rmtree(temp_repo_path, onerror=onerror)
86 | existing_files = [repo_path + '/' + file_path for file_path in file_paths]
87 | new_files = [temp_repo_path + '/' + file_path for file_path in file_paths]
88 |
89 | repo = Repo(repo_path)
90 | new_repo = repo.clone(temp_repo_path)
91 | new_repo.remotes.origin.set_url(repo.remotes.origin.url)
92 | new_repo.git.checkout("master")
93 | new_branch_name = 'gitplus-' + ''.join(random.choices(string.ascii_lowercase + string.digits, k = 8))
94 | new_repo.git.checkout('-b', new_branch_name)
95 |
96 | for i, existing_file in enumerate(existing_files):
97 | copyfile(existing_file, new_files[i])
98 |
99 | for file in file_paths:
100 | new_repo.git.add(file)
101 |
102 | new_repo.index.commit(commit_msg)
103 | new_repo.git.push('--set-upstream', 'origin', new_repo.active_branch.name)
104 | repo.remotes.origin.fetch(new_branch_name + ':' + new_branch_name) # fetch newly created branch
105 | owner_login, repo_name = get_owner_login_and_repo_name(new_repo)
106 | base_owner_login, base_repo_name, head, base = get_repository_details_for_pr(owner_login, repo_name, self.github_token, new_branch_name)
107 | result = create_pull_request(
108 | owner_login=base_owner_login,
109 | repo_name=base_repo_name,
110 | title=pr_title,
111 | head=head,
112 | base=base,
113 | access_token=self.github_token,
114 | reviewnb_endpoint=self.reviewnb_endpoint,
115 | )
116 | self.finish(json.dumps(result))
117 | except Exception as ex:
118 | logger.error('/gitplus/pull_request request payload: ' + str(body))
119 | logger.error(traceback.format_exc())
120 | raise ex
121 |
122 |
123 | class CommitHandler(IPythonHandler):
124 | def initialize(self, context):
125 | self.github_token = context["github_token"]
126 | self.reviewnb_endpoint = context["reviewnb_endpoint"]
127 |
128 | def post(self):
129 | """
130 | Push commit
131 | """
132 | body = {}
133 | try:
134 | body = json.loads(self.request.body)
135 | file_paths = body['files']
136 | repo_path = body['repo_path']
137 | commit_msg = body['commit_message']
138 | repo = Repo(repo_path)
139 | old_commit = repo.head.commit.hexsha
140 |
141 | for file in file_paths:
142 | repo.git.add(file)
143 |
144 | repo.index.commit(commit_msg)
145 | repo.git.push('--set-upstream', 'origin', repo.active_branch.name) # --set-upstream will create remote branch if required
146 | new_commit = repo.head.commit.hexsha
147 |
148 | if old_commit != new_commit:
149 | owner_login, repo_name = get_owner_login_and_repo_name(repo)
150 | github_url = GITHUB_ENDPOINT + owner_login + '/' + repo_name + '/commit/' + new_commit
151 | reviewnb_url = self.reviewnb_endpoint + owner_login + '/' + repo_name + '/commit/' + new_commit
152 |
153 | result = {
154 | "github_url": github_url,
155 | "reviewnb_url": reviewnb_url
156 | }
157 |
158 | self.finish(json.dumps(result))
159 | except Exception as ex:
160 | logger.error('/gitplus/commit request payload: ' + str(body))
161 | logger.error(traceback.format_exc())
162 | raise(ex)
163 |
164 |
165 | class ServerConfigHandler(IPythonHandler):
166 | def initialize(self, context):
167 | self.server_root_dir = context["server_root_dir"]
168 |
169 | def get(self):
170 | """
171 | Returns following config,
172 | 1. server_root_dir: Expanded root directory of server
173 | This can be read directly on client side with "PageConfig.getOption('serverRoot')" but that includes tilde (~). With this API we return tilde expanded absolute server root dir.
174 | """
175 | if not self.server_root_dir:
176 | raise RuntimeError('Could not read server_root_dir from Jupyter!')
177 |
178 | result = {
179 | 'server_root_dir': os.path.expanduser(self.server_root_dir).replace(os.sep, '/')
180 | }
181 | self.finish(json.dumps(result))
182 |
--------------------------------------------------------------------------------
/jupyterlab_gitplus/requests.py:
--------------------------------------------------------------------------------
1 | import requests
2 | from urllib3.util.retry import Retry
3 | from requests.adapters import HTTPAdapter
4 |
5 |
6 | def retriable_requests(retries=3, backoff_factor=0.1, status_forcelist=(500, 502, 503, 504)):
7 | session = requests.Session()
8 | retry_policy = Retry(
9 | total=retries,
10 | backoff_factor=backoff_factor,
11 | status_forcelist=status_forcelist,
12 | )
13 | session.mount('http://', HTTPAdapter(max_retries=retry_policy))
14 | session.mount('https://', HTTPAdapter(max_retries=retry_policy))
15 | return session
16 |
--------------------------------------------------------------------------------
/jupyterlab_gitplus/utils.py:
--------------------------------------------------------------------------------
1 |
2 | import re
3 | import os
4 | import stat
5 | import logging
6 | logger = logging.getLogger(__name__)
7 |
8 | GITHUB_REMOTE_URL_REGEX = re.compile(r"github\.com[:\/](.*?)\/(.*?)\.git")
9 |
10 |
11 | def get_owner_login_and_repo_name(repo):
12 | owner_login, repo_name = '', ''
13 | remote_url = repo.remotes.origin.url
14 |
15 | if not remote_url.endswith('.git'):
16 | remote_url += '.git'
17 |
18 | match = GITHUB_REMOTE_URL_REGEX.search(remote_url)
19 |
20 | if match:
21 | owner_login = match.group(1)
22 | repo_name = match.group(2)
23 | logger.info(f"For git repo {remote_url}, found {owner_login}/{repo_name}")
24 | else:
25 | logger.error(f"Unable to find owner/repo name for repo {remote_url}")
26 |
27 | return owner_login, repo_name
28 |
29 |
30 | def onerror(func, path, exc_info):
31 | """
32 | Error handler for ``shutil.rmtree``.
33 |
34 | If the error is due to an access error (read only file)
35 | it attempts to add write permission and then retries.
36 |
37 | If the error is for another reason it re-raises the error.
38 |
39 | Usage : ``shutil.rmtree(path, onerror=onerror)``
40 |
41 | Copied from: https://stackoverflow.com/a/2656405/10674324
42 | """
43 | if exc_info[0].__name__ == 'FileNotFoundError':
44 | # folder does not exist, no need to delete
45 | pass
46 | elif not os.access(path, os.W_OK):
47 | # Is the error an access error ?
48 | os.chmod(path, stat.S_IWUSR)
49 | func(path)
50 | else:
51 | raise
52 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@reviewnb/jupyterlab_gitplus",
3 | "version": "0.1.5",
4 | "description": "JupyterLab extension to create GitHub pull requests & commits.",
5 | "keywords": [
6 | "jupyter",
7 | "jupyterlab",
8 | "jupyterlab-extension"
9 | ],
10 | "homepage": "https://github.com/ReviewNB/jupyterlab-gitplus",
11 | "bugs": {
12 | "url": "https://github.com/ReviewNB/jupyterlab-gitplus/issues"
13 | },
14 | "license": "BSD-3-Clause",
15 | "author": "Amit Rathi",
16 | "files": [
17 | "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}",
18 | "style/**/*.{css,eot,gif,html,jpg,json,png,svg,woff2,ttf}"
19 | ],
20 | "main": "lib/index.js",
21 | "types": "lib/index.d.ts",
22 | "style": "style/index.css",
23 | "repository": {
24 | "type": "git",
25 | "url": "https://github.com/ReviewNB/jupyterlab-gitplus.git"
26 | },
27 | "scripts": {
28 | "build": "jlpm run build:lib && jlpm run build:labextension:dev",
29 | "build:labextension": "jupyter labextension build .",
30 | "build:labextension:dev": "jupyter labextension build --development True .",
31 | "build:lib": "tsc",
32 | "build:prod": "jlpm run clean && jlpm run build:lib && jlpm run build:labextension",
33 | "clean": "jlpm run clean:lib",
34 | "clean:all": "jlpm run clean:lib && jlpm run clean:labextension",
35 | "clean:labextension": "rimraf jupyterlab_gitplus/labextension",
36 | "clean:lib": "rimraf lib tsconfig.tsbuildinfo",
37 | "eslint": "eslint . --ext .ts,.tsx --fix",
38 | "eslint:check": "eslint . --ext .ts,.tsx",
39 | "install:extension": "jlpm run build",
40 | "prepare": "jlpm run clean && jlpm run build",
41 | "watch": "run-p watch:src watch:labextension",
42 | "watch:labextension": "jupyter labextension watch .",
43 | "watch:src": "tsc -w"
44 | },
45 | "dependencies": {
46 | "@jupyterlab/application": "^3.0.11",
47 | "@jupyterlab/apputils": "^3.0.9",
48 | "@jupyterlab/docregistry": "^3.0.11",
49 | "@jupyterlab/fileeditor": "^3.0.11",
50 | "@jupyterlab/mainmenu": "^3.0.9",
51 | "@jupyterlab/notebook": "^3.0.11",
52 | "@lumino/application": "^1.13.1",
53 | "@lumino/disposable": "^1.4.3",
54 | "axios": "^0.19.2"
55 | },
56 | "devDependencies": {
57 | "@jupyterlab/builder": "^3.0.0",
58 | "@typescript-eslint/eslint-plugin": "^4.8.1",
59 | "@typescript-eslint/parser": "^4.8.1",
60 | "eslint": "^7.14.0",
61 | "eslint-config-prettier": "^6.15.0",
62 | "eslint-plugin-prettier": "^3.1.4",
63 | "mkdirp": "^1.0.3",
64 | "npm-run-all": "^4.1.5",
65 | "prettier": "^2.1.1",
66 | "rimraf": "^3.0.2",
67 | "typescript": "~4.1.3"
68 | },
69 | "resolutions": {
70 | "**/@lumino/widgets": "1.37.2"
71 | },
72 | "sideEffects": [
73 | "style/*.css",
74 | "style/index.js"
75 | ],
76 | "jupyterlab": {
77 | "extension": true,
78 | "outputDir": "jupyterlab_gitplus/labextension"
79 | }
80 | }
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import re
2 | import setuptools
3 | from pathlib import Path
4 |
5 | version = re.search(
6 | '^__version__\s*=\s*"(.*)"',
7 | open('jupyterlab_gitplus/__init__.py').read(),
8 | re.M
9 | ).group(1)
10 |
11 |
12 | with open("README.md", "rb") as f:
13 | long_descr = f.read().decode("utf-8")
14 |
15 | HERE = Path(__file__).parent.resolve()
16 |
17 | # The name of the project
18 | name = "jupyterlab_gitplus"
19 |
20 |
21 | lab_path = (HERE / name.replace("-", "_") / "labextension")
22 | labext_name = "@reviewnb/jupyterlab_gitplus"
23 |
24 | # Representative files that should exist after a successful build
25 | ensured_targets = [
26 | str(lab_path / "package.json"),
27 | str(lab_path / "static/style.js")
28 | ]
29 |
30 | data_files_spec = [
31 | ("share/jupyter/labextensions/%s" % labext_name, str(lab_path), "**"),
32 | ("share/jupyter/labextensions/%s" % labext_name, str(HERE), "install.json"),
33 | ("etc/jupyter/jupyter_server_config.d", "jupyter-config/server-config", "jupyterlab_gitplus.json"),
34 | # For backward compatibility with notebook server
35 | ("etc/jupyter/jupyter_notebook_config.d", "jupyter-config/nb-config", "jupyterlab_gitplus.json"),
36 | ]
37 |
38 | setup_args = dict(
39 | name = "jupyterlab_gitplus",
40 | packages = ["jupyterlab_gitplus"],
41 | python_requires='>=3',
42 | version = version,
43 | description = "JupyterLab extension to create GitHub pull requests",
44 | long_description = long_descr,
45 | long_description_content_type="text/markdown",
46 | author = "Amit Rathi",
47 | author_email = "amit@reviewnb.com",
48 | url = "https://github.com/ReviewNB/jupyterlab-gitplus",
49 | keywords=['github', 'jupyter', 'notebook', 'pull request', 'version control', 'git'],
50 | include_package_data=True,
51 | platforms="Linux, Mac OS X, Windows",
52 | install_requires=[
53 | 'jupyterlab',
54 | 'gitpython',
55 | 'requests',
56 | 'urllib3',
57 | 'jupyter_server>=1.6,<3'
58 | ]
59 | )
60 |
61 |
62 | from jupyter_packaging import (
63 | wrap_installers,
64 | npm_builder,
65 | get_data_files
66 | )
67 |
68 | post_develop = npm_builder(build_cmd="install:extension", source_dir="src", build_dir=lab_path)
69 | setup_args['cmdclass'] = wrap_installers(post_develop=post_develop, ensured_targets=ensured_targets)
70 | setup_args['data_files'] = get_data_files(data_files_spec)
71 |
72 | if __name__ == "__main__":
73 | setuptools.setup(**setup_args)
74 |
--------------------------------------------------------------------------------
/src/api_client.ts:
--------------------------------------------------------------------------------
1 | import { PageConfig } from '@jupyterlab/coreutils';
2 | import { Dialog } from '@jupyterlab/apputils';
3 | import axios from 'axios';
4 | import { show_spinner } from './index';
5 |
6 | export const HTTP = axios.create({
7 | baseURL: PageConfig.getBaseUrl()
8 | });
9 |
10 | HTTP.defaults.headers.post['X-CSRFToken'] = _get_cookie('_xsrf');
11 |
12 | function _get_cookie(name: string) {
13 | // Source: https://blog.jupyter.org/security-release-jupyter-notebook-4-3-1-808e1f3bb5e2
14 | const r = document.cookie.match('\\b' + name + '=([^;]*)\\b');
15 | return r ? r[1] : undefined;
16 | }
17 |
18 | export function get_server_config() {
19 | return HTTP.get('gitplus/expanded_server_root')
20 | .then(response => {
21 | return response.data;
22 | })
23 | .catch(error => {
24 | console.log(error);
25 | });
26 | }
27 |
28 | export function get_modified_repositories(
29 | data: {},
30 | show_repository_selection_dialog: Function,
31 | command: string,
32 | show_repository_selection_failure_dialog: Function
33 | ) {
34 | const repo_names: string[][] = [];
35 | return HTTP.post('gitplus/modified_repo', data)
36 | .then(response => {
37 | const repo_list = response.data;
38 | for (const repo of repo_list) {
39 | const display_name = repo['name'] + ' (' + repo['path'] + ')';
40 | repo_names.push([display_name, repo['path']]);
41 | }
42 | show_repository_selection_dialog(repo_names, command);
43 | })
44 | .catch(error => {
45 | show_repository_selection_failure_dialog();
46 | console.log(error);
47 | });
48 | }
49 |
50 | export function create_pull_request(
51 | data: {},
52 | show_pr_created_dialog: Function
53 | ) {
54 | show_spinner();
55 | return HTTP.post('gitplus/pull_request', data)
56 | .then(response => {
57 | const result = response.data;
58 | const github_url = result['github_url'];
59 | const reviewnb_url = result['reviewnb_url'];
60 | Dialog.flush(); // remove spinner
61 | show_pr_created_dialog(github_url, reviewnb_url);
62 | })
63 | .catch(error => {
64 | console.log(error);
65 | Dialog.flush(); // remove spinner
66 | show_pr_created_dialog();
67 | });
68 | }
69 |
70 | export function create_and_push_commit(
71 | data: {},
72 | show_commit_pushed_dialog: Function
73 | ) {
74 | show_spinner();
75 | return HTTP.post('gitplus/commit', data)
76 | .then(response => {
77 | const result = response.data;
78 | const github_url = result['github_url'];
79 | const reviewnb_url = result['reviewnb_url'];
80 | Dialog.flush(); // remove spinner
81 | show_commit_pushed_dialog(github_url, reviewnb_url);
82 | })
83 | .catch(error => {
84 | console.log(error);
85 | Dialog.flush(); // remove spinner
86 | show_commit_pushed_dialog();
87 | });
88 | }
89 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | JupyterFrontEnd,
3 | JupyterFrontEndPlugin
4 | } from '@jupyterlab/application';
5 | import { Dialog, showDialog, showErrorMessage } from '@jupyterlab/apputils';
6 | import { IEditorTracker } from '@jupyterlab/fileeditor';
7 | import { INotebookTracker } from '@jupyterlab/notebook';
8 | import { IMainMenu } from '@jupyterlab/mainmenu';
9 | import { Menu } from '@lumino/widgets';
10 | import { get_json_request_payload_from_file_list } from './utility';
11 | import {
12 | get_modified_repositories,
13 | create_pull_request,
14 | create_and_push_commit,
15 | get_server_config
16 | } from './api_client';
17 | import {
18 | CheckBoxes,
19 | DropDown,
20 | CommitPRMessageDialog,
21 | CommitMessageDialog,
22 | PRCreated,
23 | CommitPushed,
24 | SpinnerDialog
25 | } from './ui_elements';
26 |
27 | /**
28 | * The plugin registration information.
29 | */
30 | const gitPlusPlugin: JupyterFrontEndPlugin = {
31 | activate,
32 | requires: [IMainMenu, IEditorTracker, INotebookTracker],
33 | id: '@reviewnb/gitplus',
34 | autoStart: true
35 | };
36 |
37 | /**
38 | * Activate the extension.
39 | */
40 | function activate(
41 | app: JupyterFrontEnd,
42 | mainMenu: IMainMenu,
43 | editorTracker: IEditorTracker,
44 | notebookTracker: INotebookTracker
45 | ) {
46 | console.log(
47 | 'JupyterLab extension @reviewnb/gitplus (0.1.5) is activated!'
48 | );
49 | const createPRCommand = 'create-pr';
50 | app.commands.addCommand(createPRCommand, {
51 | label: 'Create Pull Request',
52 | execute: () => {
53 | get_server_config()
54 | .then(config => {
55 | const files = get_open_files(
56 | editorTracker,
57 | notebookTracker,
58 | config['server_root_dir']
59 | );
60 | const data = get_json_request_payload_from_file_list(files);
61 | get_modified_repositories(
62 | data,
63 | show_repository_selection_dialog,
64 | createPRCommand,
65 | show_repository_selection_failure_dialog
66 | );
67 | })
68 | .catch(error => {
69 | show_repository_selection_failure_dialog();
70 | console.log(error);
71 | });
72 | }
73 | });
74 |
75 | const pushCommitCommand = 'push-commit';
76 | app.commands.addCommand(pushCommitCommand, {
77 | label: 'Push Commit',
78 | execute: () => {
79 | get_server_config()
80 | .then(config => {
81 | const files = get_open_files(
82 | editorTracker,
83 | notebookTracker,
84 | config['server_root_dir']
85 | );
86 | const data = get_json_request_payload_from_file_list(files);
87 | get_modified_repositories(
88 | data,
89 | show_repository_selection_dialog,
90 | pushCommitCommand,
91 | show_repository_selection_failure_dialog
92 | );
93 | })
94 | .catch(error => {
95 | show_repository_selection_failure_dialog();
96 | console.log(error);
97 | });
98 | }
99 | });
100 |
101 | function show_repository_selection_dialog(
102 | repo_names: string[][],
103 | command: string
104 | ) {
105 | if (repo_names.length == 0) {
106 | let msg =
107 | "No GitHub repositories found! \n\nFirst, open the files that you'd like to commit or create pull request for.";
108 | if (command == createPRCommand) {
109 | msg =
110 | "No GitHub repositories found! \n\nFirst, open the files that you'd like to create pull request for.";
111 | } else if (command == pushCommitCommand) {
112 | msg =
113 | "No GitHub repositories found! \n\nFirst, open the files that you'd like to commit.";
114 | }
115 | showDialog({
116 | title: 'Repository Selection',
117 | body: msg,
118 | buttons: [Dialog.okButton({ label: 'Okay' })]
119 | }).then(result => { });
120 | } else {
121 | const label_style = {
122 | 'font-size': '14px'
123 | };
124 | const body_style = {
125 | 'padding-top': '2em',
126 | 'padding-bottom': '2em',
127 | 'border-top': '1px solid #dfe2e5'
128 | };
129 | const select_style = {
130 | 'margin-top': '4px',
131 | 'min-height': '32px'
132 | };
133 | const styles = {
134 | label_style: label_style,
135 | body_style: body_style,
136 | select_style: select_style
137 | };
138 | const dwidget = new DropDown(repo_names, 'Select Repository', styles);
139 | showDialog({
140 | title: 'Repository Selection',
141 | body: dwidget,
142 | buttons: [Dialog.cancelButton(), Dialog.okButton({ label: 'Next' })]
143 | }).then(result => {
144 | if (!result.button.accept) {
145 | return;
146 | }
147 | const repo_name = dwidget.getTo();
148 | show_file_selection_dialog(repo_name, command);
149 | });
150 | }
151 | }
152 |
153 | function show_file_selection_dialog(repo_path: string, command: string) {
154 | get_server_config()
155 | .then(config => {
156 | const files = get_open_files(
157 | editorTracker,
158 | notebookTracker,
159 | config['server_root_dir']
160 | );
161 | const relevant_files: string[] = [];
162 |
163 | for (const f of files) {
164 | if (f.startsWith(repo_path)) {
165 | relevant_files.push(f.substring(repo_path.length + 1));
166 | }
167 | }
168 |
169 | const cwidget = new CheckBoxes(relevant_files);
170 | showDialog({
171 | title: 'Select Files',
172 | body: cwidget,
173 | buttons: [Dialog.cancelButton(), Dialog.okButton({ label: 'Next' })]
174 | }).then(result => {
175 | if (!result.button.accept) {
176 | return;
177 | }
178 | const files = cwidget.getSelected();
179 |
180 | if (command == createPRCommand) {
181 | show_commit_pr_message_dialog(repo_path, files);
182 | } else if (command == pushCommitCommand) {
183 | show_commit_message_dialog(repo_path, files);
184 | }
185 | });
186 | })
187 | .catch(error => {
188 | show_file_selection_failure_dialog();
189 | console.log(error);
190 | });
191 | }
192 |
193 | function show_commit_message_dialog(repo_path: string, files: string[]) {
194 | console.log(`${repo_path} --show_commit_message_dialog-- ${files}`);
195 | const cmwidget = new CommitMessageDialog();
196 |
197 | showDialog({
198 | title: 'Provide Details',
199 | body: cmwidget,
200 | buttons: [
201 | Dialog.cancelButton(),
202 | Dialog.okButton({ label: 'Create & Push Commit' })
203 | ]
204 | }).then(result => {
205 | if (!result.button.accept) {
206 | return;
207 | }
208 | const commit_message = cmwidget.getCommitMessage();
209 | const body = {
210 | files: files,
211 | repo_path: repo_path,
212 | commit_message: commit_message
213 | };
214 | create_and_push_commit(body, show_commit_pushed_dialog);
215 | });
216 | }
217 |
218 | function show_commit_pr_message_dialog(repo_path: string, files: string[]) {
219 | console.log(`${repo_path} --show_commit_pr_message_dialog-- ${files}`);
220 | const cprwidget = new CommitPRMessageDialog();
221 |
222 | showDialog({
223 | title: 'Provide Details',
224 | body: cprwidget,
225 | buttons: [Dialog.cancelButton(), Dialog.okButton({ label: 'Create PR' })]
226 | }).then(result => {
227 | if (!result.button.accept) {
228 | return;
229 | }
230 | const commit_message = cprwidget.getCommitMessage();
231 | const pr_title = cprwidget.getPRTitle();
232 | const body = {
233 | files: files,
234 | repo_path: repo_path,
235 | commit_message: commit_message,
236 | pr_title: pr_title
237 | };
238 | create_pull_request(body, show_pr_created_dialog);
239 | });
240 | }
241 |
242 | function show_pr_created_dialog(github_url = '', reviewnb_url = '') {
243 | if (github_url.length == 0 || reviewnb_url.length == 0) {
244 | showDialog({
245 | title: 'Failure',
246 | body: "Failed to create pull request. Check Jupyter logs for error. \n\nMake sure you've correctly setup GitHub access token. Steps here - https://github.com/ReviewNB/jupyterlab-gitplus/blob/master/README.md#setup-github-token\n\nIf unable to resolve, open an issue here - https://github.com/ReviewNB/jupyterlab-gitplus/issues",
247 | buttons: [Dialog.okButton({ label: 'Okay' })]
248 | }).then(result => { });
249 | } else {
250 | const prcwidget = new PRCreated(github_url, reviewnb_url);
251 |
252 | showDialog({
253 | title: 'Pull Request Created',
254 | body: prcwidget,
255 | buttons: [Dialog.cancelButton(), Dialog.okButton({ label: 'Okay' })]
256 | }).then(result => { });
257 | }
258 | }
259 |
260 | function show_commit_pushed_dialog(github_url = '', reviewnb_url = '') {
261 | if (github_url.length == 0 || reviewnb_url.length == 0) {
262 | showDialog({
263 | title: 'Failure',
264 | body: 'Failed to create/push commit. Check Jupyter logs for error. \n\nIf unable to resolve, open an issue here - https://github.com/ReviewNB/jupyterlab-gitplus/issues',
265 | buttons: [Dialog.okButton({ label: 'Okay' })]
266 | }).then(result => { });
267 | } else {
268 | const prcwidget = new CommitPushed(github_url, reviewnb_url);
269 |
270 | showDialog({
271 | title: 'Commit pushed!',
272 | body: prcwidget,
273 | buttons: [Dialog.cancelButton(), Dialog.okButton({ label: 'Okay' })]
274 | }).then(result => {
275 | if (!result.button.accept) {
276 | return;
277 | }
278 | });
279 | }
280 | }
281 | // Create new top level menu
282 | const menu = new Menu({ commands: app.commands });
283 | menu.title.label = 'Git-Plus';
284 | mainMenu.addMenu(menu, { rank: 40 });
285 |
286 | // Add commands to menu
287 | menu.addItem({
288 | command: createPRCommand,
289 | args: {}
290 | });
291 | menu.addItem({
292 | command: pushCommitCommand,
293 | args: {}
294 | });
295 | }
296 |
297 | export function show_repository_selection_failure_dialog() {
298 | showErrorMessage(
299 | 'Failure',
300 | 'Failed to fetch list of repositories. Have you installed & enabled server side of the extension? \n\nSee installation steps here - https://github.com/ReviewNB/jupyterlab-gitplus/blob/master/README.md#install\n\nIf unable to resolve, open an issue here - https://github.com/ReviewNB/jupyterlab-gitplus/issues'
301 | );
302 | }
303 |
304 | export function show_file_selection_failure_dialog() {
305 | showErrorMessage(
306 | 'Failure',
307 | 'Failed to fetch list of modified files. Have you installed & enabled server side of the extension? \n\nSee installation steps here - https://github.com/ReviewNB/jupyterlab-gitplus/blob/master/README.md#install\n\nIf unable to resolve, open an issue here - https://github.com/ReviewNB/jupyterlab-gitplus/issues'
308 | );
309 | }
310 |
311 | export function show_spinner() {
312 | const spinWidget = new SpinnerDialog();
313 | showDialog({
314 | title: 'Waiting for response...',
315 | body: spinWidget,
316 | buttons: [Dialog.cancelButton()]
317 | }).then(result => { });
318 | }
319 |
320 | function get_open_files(
321 | editorTracker: IEditorTracker,
322 | notebookTracker: INotebookTracker,
323 | base_dir: string
324 | ) {
325 | const result: string[] = [];
326 | let separator = '/';
327 |
328 | notebookTracker.forEach(notebook => {
329 | result.push(base_dir + separator + notebook.context.path);
330 | });
331 | editorTracker.forEach(editor => {
332 | result.push(base_dir + separator + editor.context.path);
333 | });
334 |
335 | return result;
336 | }
337 |
338 | export default gitPlusPlugin;
339 |
--------------------------------------------------------------------------------
/src/ui_elements.ts:
--------------------------------------------------------------------------------
1 | import { Widget } from '@lumino/widgets';
2 | import { Spinner } from '@jupyterlab/apputils';
3 |
4 | export class SpinnerDialog extends Widget {
5 | constructor() {
6 | const spinner_style = {
7 | 'margin-top': '6em'
8 | };
9 | const body = document.createElement('div');
10 | const basic = document.createElement('div');
11 | Private.apply_style(basic, spinner_style);
12 | body.appendChild(basic);
13 | const spinner = new Spinner();
14 | basic.appendChild(spinner.node);
15 | super({ node: body });
16 | }
17 | }
18 | export class PRCreated extends Widget {
19 | constructor(github_url: string, reviewnb_url: string) {
20 | const anchor_style = {
21 | color: '#106ba3',
22 | 'text-decoration': 'underline'
23 | };
24 |
25 | const body = document.createElement('div');
26 | const basic = document.createElement('div');
27 | basic.classList.add('gitPlusDialogBody');
28 | body.appendChild(basic);
29 | basic.appendChild(Private.buildLabel('See pull request on GitHub: '));
30 | basic.appendChild(Private.buildNewline());
31 | basic.appendChild(
32 | Private.buildAnchor(github_url, github_url, anchor_style)
33 | );
34 | basic.appendChild(Private.buildNewline());
35 | basic.appendChild(Private.buildNewline());
36 | basic.appendChild(Private.buildNewline());
37 | basic.appendChild(Private.buildLabel('See pull request on ReviewNB: '));
38 | basic.appendChild(Private.buildNewline());
39 | basic.appendChild(
40 | Private.buildAnchor(reviewnb_url, reviewnb_url, anchor_style)
41 | );
42 | basic.appendChild(Private.buildNewline());
43 | super({ node: body });
44 | }
45 | }
46 |
47 | export class CommitPushed extends Widget {
48 | constructor(github_url: string, reviewnb_url: string) {
49 | const anchor_style = {
50 | color: '#106ba3',
51 | 'text-decoration': 'underline'
52 | };
53 |
54 | const body = document.createElement('div');
55 | const basic = document.createElement('div');
56 | basic.classList.add('gitPlusDialogBody');
57 | body.appendChild(basic);
58 | basic.appendChild(Private.buildLabel('See commit on GitHub: '));
59 | basic.appendChild(Private.buildNewline());
60 | basic.appendChild(
61 | Private.buildAnchor(github_url, github_url, anchor_style)
62 | );
63 | basic.appendChild(Private.buildNewline());
64 | basic.appendChild(Private.buildNewline());
65 | basic.appendChild(Private.buildNewline());
66 | basic.appendChild(Private.buildLabel('See commit on ReviewNB: '));
67 | basic.appendChild(Private.buildNewline());
68 | basic.appendChild(
69 | Private.buildAnchor(reviewnb_url, reviewnb_url, anchor_style)
70 | );
71 | basic.appendChild(Private.buildNewline());
72 | super({ node: body });
73 | }
74 | }
75 |
76 | export class DropDown extends Widget {
77 | constructor(options: string[][] = [], label = '', styles: {} = {}) {
78 | let body_style = {};
79 | let label_style = {};
80 | let select_style = {};
81 |
82 | if ('body_style' in styles) {
83 | body_style = styles['body_style'];
84 | }
85 | if ('label_style' in styles) {
86 | label_style = styles['label_style'];
87 | }
88 | if ('select_style' in styles) {
89 | select_style = styles['select_style'];
90 | }
91 |
92 | const body = document.createElement('div');
93 | Private.apply_style(body, body_style);
94 | const basic = document.createElement('div');
95 | body.appendChild(basic);
96 | basic.appendChild(Private.buildLabel(label, label_style));
97 | basic.appendChild(Private.buildSelect(options, select_style));
98 | super({ node: body });
99 | }
100 |
101 | get toNode(): HTMLSelectElement {
102 | return this.node.getElementsByTagName('select')[0] as HTMLSelectElement;
103 | }
104 |
105 | public getTo(): string {
106 | return this.toNode.value;
107 | }
108 | }
109 |
110 | export class CheckBoxes extends Widget {
111 | constructor(items: string[] = []) {
112 | const basic = document.createElement('div');
113 | basic.classList.add('gitPlusDialogBody');
114 |
115 | for (const item of items) {
116 | basic.appendChild(Private.buildCheckbox(item));
117 | }
118 | super({ node: basic });
119 | }
120 |
121 | public getSelected(): string[] {
122 | const result: string[] = [];
123 | const inputs = this.node.getElementsByTagName('input');
124 |
125 | for (const input of inputs) {
126 | if (input.checked) {
127 | result.push(input.id);
128 | }
129 | }
130 |
131 | return result;
132 | }
133 | }
134 |
135 | export class CommitPRMessageDialog extends Widget {
136 | constructor() {
137 | const body = document.createElement('div');
138 | const basic = document.createElement('div');
139 | basic.classList.add('gitPlusDialogBody');
140 | body.appendChild(basic);
141 | basic.appendChild(Private.buildLabel('Commit message: '));
142 | basic.appendChild(
143 | Private.buildTextarea(
144 | 'Enter your commit message',
145 | 'gitplus-commit-message',
146 | 'gitPlusMessageTextArea'
147 | )
148 | );
149 | basic.appendChild(Private.buildLabel('PR title: '));
150 | basic.appendChild(
151 | Private.buildTextarea(
152 | 'Enter title for pull request',
153 | 'gitplus-pr-message',
154 | 'gitPlusMessageTextArea'
155 | )
156 | );
157 | super({ node: body });
158 | }
159 |
160 | public getCommitMessage(): string {
161 | const textareas = this.node.getElementsByTagName('textarea');
162 | for (const textarea of textareas) {
163 | if (textarea.id == 'gitplus-commit-message') {
164 | return textarea.value;
165 | }
166 | }
167 | return '';
168 | }
169 |
170 | public getPRTitle(): string {
171 | const textareas = this.node.getElementsByTagName('textarea');
172 | for (const textarea of textareas) {
173 | if (textarea.id == 'gitplus-pr-message') {
174 | return textarea.value;
175 | }
176 | }
177 | return '';
178 | }
179 | }
180 |
181 | export class CommitMessageDialog extends Widget {
182 | constructor() {
183 | const body = document.createElement('div');
184 | const basic = document.createElement('div');
185 | basic.classList.add('gitPlusDialogBody');
186 | body.appendChild(basic);
187 | basic.appendChild(Private.buildLabel('Commit message: '));
188 | basic.appendChild(
189 | Private.buildTextarea(
190 | 'Enter your commit message',
191 | 'gitplus-commit-message',
192 | 'gitPlusMessageTextArea'
193 | )
194 | );
195 | super({ node: body });
196 | }
197 |
198 | public getCommitMessage(): string {
199 | const textareas = this.node.getElementsByTagName('textarea');
200 | for (const textarea of textareas) {
201 | if (textarea.id == 'gitplus-commit-message') {
202 | return textarea.value;
203 | }
204 | }
205 | return '';
206 | }
207 | }
208 |
209 | namespace Private {
210 | const default_none = document.createElement('option');
211 | default_none.selected = false;
212 | default_none.disabled = true;
213 | default_none.hidden = false;
214 | default_none.style.display = 'none';
215 | default_none.value = '';
216 |
217 | export function buildLabel(text: string, style: {} = {}): HTMLLabelElement {
218 | const label = document.createElement('label');
219 | label.textContent = text;
220 | apply_style(label, style);
221 | return label;
222 | }
223 |
224 | export function buildAnchor(
225 | url: string,
226 | text: string,
227 | style: {} = {}
228 | ): HTMLAnchorElement {
229 | const anchor = document.createElement('a');
230 | anchor.href = url;
231 | anchor.text = text;
232 | anchor.target = '_blank';
233 | apply_style(anchor, style);
234 | return anchor;
235 | }
236 |
237 | export function buildNewline(): HTMLBRElement {
238 | return document.createElement('br');
239 | }
240 |
241 | export function buildCheckbox(text: string): HTMLSpanElement {
242 | const span = document.createElement('span');
243 | const label = document.createElement('label');
244 | const input = document.createElement('input');
245 | input.classList.add('gitPlusCheckbox');
246 | input.id = text;
247 | input.type = 'checkbox';
248 | label.htmlFor = text;
249 | label.textContent = text;
250 | span.appendChild(input);
251 | span.appendChild(label);
252 | return span;
253 | }
254 |
255 | export function buildTextarea(
256 | text: string,
257 | id: string,
258 | _class: string
259 | ): HTMLTextAreaElement {
260 | const area = document.createElement('textarea');
261 | area.placeholder = text;
262 | area.id = id;
263 | area.classList.add(_class);
264 | return area;
265 | }
266 |
267 | export function buildSelect(
268 | list: string[][],
269 | style: {} = {},
270 | def?: string
271 | ): HTMLSelectElement {
272 | const select = document.createElement('select');
273 | select.appendChild(default_none);
274 | for (const x of list) {
275 | const option = document.createElement('option');
276 | option.value = x[1];
277 | option.textContent = x[0];
278 | select.appendChild(option);
279 |
280 | if (def && x[0] === def) {
281 | option.selected = true;
282 | }
283 | }
284 | apply_style(select, style);
285 | return select;
286 | }
287 |
288 | export function apply_style(element: HTMLElement, style: {}) {
289 | if ('margin-top' in style) {
290 | element.style.marginTop = style['margin-top'];
291 | }
292 | if ('margin-bottom' in style) {
293 | element.style.marginBottom = style['margin-bottom'];
294 | }
295 | if ('padding-top' in style) {
296 | element.style.paddingTop = style['padding-top'];
297 | }
298 | if ('padding-bottom' in style) {
299 | element.style.paddingBottom = style['padding-bottom'];
300 | }
301 | if ('border-top' in style) {
302 | element.style.borderTop = style['border-top'];
303 | }
304 | if ('display' in style) {
305 | element.style.display = style['display'];
306 | }
307 | if ('min-width' in style) {
308 | element.style.minWidth = style['min-width'];
309 | }
310 | if ('min-height' in style) {
311 | element.style.minHeight = style['min-height'];
312 | }
313 | if ('color' in style) {
314 | element.style.color = style['color'];
315 | }
316 | if ('text-decoration' in style) {
317 | element.style.textDecoration = style['text-decoration'];
318 | }
319 | if ('font-size' in style) {
320 | element.style.fontSize = style['font-size'];
321 | }
322 | return element;
323 | }
324 | }
325 |
--------------------------------------------------------------------------------
/src/utility.ts:
--------------------------------------------------------------------------------
1 | export function get_json_request_payload_from_file_list(files: string[]) {
2 | const file_list = [];
3 | for (const f of files) {
4 | const entry = {
5 | path: f
6 | };
7 | file_list.push(entry);
8 | }
9 | return {
10 | files: file_list
11 | };
12 | }
13 |
--------------------------------------------------------------------------------
/style/index.css:
--------------------------------------------------------------------------------
1 | /*
2 | SAME SETTINGS TO BE COPIED TO style/base.css
3 |
4 | JupyterLab 3.0 requires the custom CSS in base.css
5 | JupyterLab 2.0 requires the custom CSS in index.css
6 | */
7 |
8 |
9 | .f1st5hdn svg {
10 | /* To center down arrow on Select Repository DropDown */
11 | top: 12px !important;
12 | }
13 |
14 | .gitPlusDialogBody {
15 | padding-top: 2em;
16 | padding-bottom: 2em;
17 | border-top: 1px solid #dfe2e5;
18 | }
19 |
20 | .gitPlusMessageTextArea {
21 | margin-top: 3px;
22 | margin-bottom: 15px;
23 | display: block;
24 | min-width: 30em;
25 | min-height: 3em;
26 | font-size: 14px;
27 | }
28 |
29 | .jp-Dialog-body {
30 | /* For newlines in error message to be visible */
31 | white-space: pre-wrap;
32 | }
33 |
34 | .gitPlusCheckbox {
35 | /* Fixes checkbox compatibility with JupyterLab 3.0 */
36 | height: auto !important;
37 | appearance: revert !important;
38 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowSyntheticDefaultImports": true,
4 | "composite": true,
5 | "declaration": true,
6 | "esModuleInterop": true,
7 | "incremental": true,
8 | "jsx": "react",
9 | "module": "esnext",
10 | "moduleResolution": "node",
11 | "noEmitOnError": true,
12 | "noImplicitAny": true,
13 | "noUnusedLocals": true,
14 | "preserveWatchOutput": true,
15 | "resolveJsonModule": true,
16 | "outDir": "lib",
17 | "rootDir": "src",
18 | "strict": true,
19 | "strictNullChecks": true,
20 | "target": "es2018",
21 | "lib": ["es2018", "dom", "dom.iterable"],
22 | "types": []
23 | },
24 | "include": ["src/*"]
25 | }
26 |
--------------------------------------------------------------------------------