Skip to content

Commit 07ee168

Browse files
DenisaCGkrassowskifcollonval
authored
Add new tag feature (#1264)
* Add new tag feature * remove debugging parts * cosmetic changes * Apply cosmetic suggestions from code review Co-authored-by: Michał Krassowski <5832902+krassowski@users.noreply.github.com> * fix dialog box scroll behavior and add tests * change to function components * fix states * remove GitGraph when filtering and fix endpoint for creating a new tag * add refresh tags list functionality * code improvements * Apply suggestions from code review Co-authored-by: Frédéric Collonval <fcollonval@users.noreply.github.com> * Update src/components/NewTagDialog.tsx Co-authored-by: Frédéric Collonval <fcollonval@users.noreply.github.com> * tests improvements * add useCallback hooks * fix TagMenu test * Apply suggestions from code review Co-authored-by: Frédéric Collonval <fcollonval@users.noreply.github.com> * fix TagMenu test * lint files * delete unnecessary code * Apply suggestions from code review Co-authored-by: Frédéric Collonval <fcollonval@users.noreply.github.com> * fix switch tag test * change all files to the new name for creating a new tag function * fix test_tag.py * Skip browser check for now --------- Co-authored-by: Michał Krassowski <5832902+krassowski@users.noreply.github.com> Co-authored-by: Frédéric Collonval <fcollonval@users.noreply.github.com>
1 parent 44c26e7 commit 07ee168

File tree

14 files changed

+1091
-34
lines changed

14 files changed

+1091
-34
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ jobs:
5050
5151
jupyter labextension list
5252
jupyter labextension list 2>&1 | grep -ie "@jupyterlab/git.*OK"
53-
python -m jupyterlab.browser_check
53+
# python -m jupyterlab.browser_check
5454
5555
- name: Package the extension
5656
run: |

jupyterlab_git/git.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1767,6 +1767,32 @@ async def tag_checkout(self, path, tag):
17671767
"message": error,
17681768
}
17691769

1770+
async def set_tag(self, path, tag, commitId):
1771+
"""Set a git tag pointing to a specific commit.
1772+
1773+
path: str
1774+
Git path repository
1775+
tag : str
1776+
Name of new tag.
1777+
commitId:
1778+
Identifier of commit tag is pointing to.
1779+
"""
1780+
command = ["git", "tag", tag, commitId]
1781+
code, _, error = await self.__execute(command, cwd=path)
1782+
if code == 0:
1783+
return {
1784+
"code": code,
1785+
"message": "Tag {} created, pointing to commit {}".format(
1786+
tag, commitId
1787+
),
1788+
}
1789+
else:
1790+
return {
1791+
"code": code,
1792+
"command": " ".join(command),
1793+
"message": error,
1794+
}
1795+
17701796
async def check_credential_helper(self, path: str) -> Optional[bool]:
17711797
"""
17721798
Check if the credential helper exists, and whether we need to setup a Git credential cache daemon in case the credential helper is Git credential cache.

jupyterlab_git/handlers.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,27 @@ async def post(self, path: str = ""):
895895
self.finish(json.dumps(result))
896896

897897

898+
class GitNewTagHandler(GitHandler):
899+
"""
900+
Hadler for 'git tag <tag_name> <commit_id>. Create new tag pointing to a specific commit.
901+
"""
902+
903+
@tornado.web.authenticated
904+
async def post(self, path: str = ""):
905+
"""
906+
POST request handler, create a new tag pointing to a specific commit.
907+
"""
908+
data = self.get_json_body()
909+
tag = data["tag_id"]
910+
commit = data["commit_id"]
911+
response = await self.git.set_tag(self.url2localpath(path), tag, commit)
912+
if response["code"] == 0:
913+
self.set_status(201)
914+
else:
915+
self.set_status(500)
916+
self.finish(json.dumps(response))
917+
918+
898919
class GitRebaseHandler(GitHandler):
899920
"""
900921
Handler for git rebase '<rebase_onto>'.
@@ -1069,6 +1090,7 @@ def setup_handlers(web_app):
10691090
("/ignore", GitIgnoreHandler),
10701091
("/tags", GitTagHandler),
10711092
("/tag_checkout", GitTagCheckoutHandler),
1093+
("/tag", GitNewTagHandler),
10721094
("/add", GitAddHandler),
10731095
("/rebase", GitRebaseHandler),
10741096
("/stash", GitStashHandler),

jupyterlab_git/tests/test_tag.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,36 @@ async def test_git_tag_checkout_success():
5757
"code": 0,
5858
"message": "Tag {} checked out".format(tag),
5959
} == actual_response
60+
61+
62+
@pytest.mark.asyncio
63+
async def test_set_tag_succes():
64+
with patch("os.environ", {"TEST": "test"}):
65+
with patch("jupyterlab_git.git.execute") as mock_execute:
66+
tag = "mock_tag"
67+
commitId = "mock_commit_id"
68+
# Given
69+
mock_execute.return_value = maybe_future((0, "", ""))
70+
71+
# When
72+
actual_response = await Git().set_tag(
73+
"test_curr_path", "mock_tag", "mock_commit_id"
74+
)
75+
76+
# Then
77+
mock_execute.assert_called_once_with(
78+
["git", "tag", tag, commitId],
79+
cwd="test_curr_path",
80+
timeout=20,
81+
env=None,
82+
username=None,
83+
password=None,
84+
is_binary=False,
85+
)
86+
87+
assert {
88+
"code": 0,
89+
"message": "Tag {} created, pointing to commit {}".format(
90+
tag, commitId
91+
),
92+
} == actual_response

src/__tests__/test-components/GitPanel.spec.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,9 @@ describe('GitPanel', () => {
271271
headChanged: {
272272
connect: jest.fn()
273273
},
274+
tagsChanged: {
275+
connect: jest.fn()
276+
},
274277
markChanged: {
275278
connect: jest.fn()
276279
},
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import { mount, shallow } from 'enzyme';
2+
import 'jest';
3+
import * as React from 'react';
4+
import { TagMenu, ITagMenuProps } from '../../components/TagMenu';
5+
import * as git from '../../git';
6+
import { Logger } from '../../logger';
7+
import { GitExtension } from '../../model';
8+
import { IGitExtension } from '../../tokens';
9+
import { listItemClass } from '../../style/BranchMenu';
10+
import {
11+
mockedRequestAPI,
12+
defaultMockedResponses,
13+
DEFAULT_REPOSITORY_PATH
14+
} from '../utils';
15+
import ClearIcon from '@material-ui/icons/Clear';
16+
import { nullTranslator } from '@jupyterlab/translation';
17+
18+
jest.mock('../../git');
19+
jest.mock('@jupyterlab/apputils');
20+
21+
const TAGS = [
22+
{
23+
name: '1.0.0'
24+
},
25+
{
26+
name: 'feature-1'
27+
},
28+
{
29+
name: 'feature-2'
30+
},
31+
{
32+
name: 'patch-007'
33+
}
34+
];
35+
36+
async function createModel() {
37+
const model = new GitExtension();
38+
model.pathRepository = DEFAULT_REPOSITORY_PATH;
39+
40+
await model.ready;
41+
return model;
42+
}
43+
44+
describe('TagMenu', () => {
45+
let model: GitExtension;
46+
const trans = nullTranslator.load('jupyterlab_git');
47+
48+
beforeEach(async () => {
49+
jest.restoreAllMocks();
50+
51+
const mock = git as jest.Mocked<typeof git>;
52+
mock.requestAPI.mockImplementation(
53+
mockedRequestAPI({
54+
responses: {
55+
...defaultMockedResponses,
56+
'tags/delete': {
57+
body: () => {
58+
return { code: 0 };
59+
}
60+
},
61+
checkout: {
62+
body: () => {
63+
return {
64+
code: 0
65+
};
66+
}
67+
}
68+
}
69+
})
70+
);
71+
72+
model = await createModel();
73+
});
74+
75+
function createProps(props?: Partial<ITagMenuProps>): ITagMenuProps {
76+
return {
77+
branching: false,
78+
pastCommits: [],
79+
logger: new Logger(),
80+
model: model as IGitExtension,
81+
tagsList: TAGS.map(tag => tag.name),
82+
trans: trans,
83+
...props
84+
};
85+
}
86+
87+
describe('constructor', () => {
88+
it('should return a new instance', () => {
89+
const menu = shallow(<TagMenu {...createProps()} />);
90+
expect(menu.instance()).toBeInstanceOf(TagMenu);
91+
});
92+
93+
it('should set the default menu filter to an empty string', () => {
94+
const menu = shallow(<TagMenu {...createProps()} />);
95+
expect(menu.state('filter')).toEqual('');
96+
});
97+
98+
it('should set the default flag indicating whether to show a dialog to create a new tag to `false`', () => {
99+
const menu = shallow(<TagMenu {...createProps()} />);
100+
expect(menu.state('tagDialog')).toEqual(false);
101+
});
102+
});
103+
104+
describe('render', () => {
105+
it('should display placeholder text for the menu filter', () => {
106+
const component = shallow(<TagMenu {...createProps()} />);
107+
const node = component.find('input[type="text"]').first();
108+
expect(node.prop('placeholder')).toEqual('Filter');
109+
});
110+
111+
it('should set a `title` attribute on the input element to filter a tag menu', () => {
112+
const component = shallow(<TagMenu {...createProps()} />);
113+
const node = component.find('input[type="text"]').first();
114+
expect(node.prop('title').length > 0).toEqual(true);
115+
});
116+
117+
it('should display a button to clear the menu filter once a filter is provided', () => {
118+
const component = shallow(<TagMenu {...createProps()} />);
119+
component.setState({
120+
filter: 'foo'
121+
});
122+
const nodes = component.find(ClearIcon);
123+
expect(nodes.length).toEqual(1);
124+
});
125+
126+
it('should set a `title` on the button to clear the menu filter', () => {
127+
const component = shallow(<TagMenu {...createProps()} />);
128+
component.setState({
129+
filter: 'foo'
130+
});
131+
const html = component.find(ClearIcon).first().html();
132+
expect(html.includes('<title>')).toEqual(true);
133+
});
134+
135+
it('should display a button to create a new tag', () => {
136+
const component = shallow(<TagMenu {...createProps()} />);
137+
const node = component.find('input[type="button"]').first();
138+
expect(node.prop('value')).toEqual('New Tag');
139+
});
140+
141+
it('should set a `title` attribute on the button to create a new tag', () => {
142+
const component = shallow(<TagMenu {...createProps()} />);
143+
const node = component.find('input[type="button"]').first();
144+
expect(node.prop('title').length > 0).toEqual(true);
145+
});
146+
147+
it('should not, by default, show a dialog to create a new tag', () => {
148+
const component = shallow(<TagMenu {...createProps()} />);
149+
const node = component.find('NewTagDialogBox').first();
150+
expect(node.prop('open')).toEqual(false);
151+
});
152+
153+
it('should show a dialog to create a new tag when the flag indicating whether to show the dialog is `true`', () => {
154+
const component = shallow(<TagMenu {...createProps()} />);
155+
component.setState({
156+
tagDialog: true
157+
});
158+
const node = component.find('NewTagDialogBox').first();
159+
expect(node.prop('open')).toEqual(true);
160+
});
161+
});
162+
163+
describe('switch tag', () => {
164+
it('should not switch to a specified tag upon clicking its corresponding element when branching is disabled', () => {
165+
const spy = jest.spyOn(GitExtension.prototype, 'checkoutTag');
166+
167+
const component = mount(<TagMenu {...createProps()} />);
168+
const nodes = component.find(
169+
`.${listItemClass}[title*="${TAGS[1].name}"]`
170+
);
171+
nodes.at(0).simulate('click');
172+
173+
expect(spy).toHaveBeenCalledTimes(0);
174+
spy.mockRestore();
175+
});
176+
177+
it('should switch to a specified tag upon clicking its corresponding element when branching is enabled', () => {
178+
const spy = jest.spyOn(GitExtension.prototype, 'checkoutTag');
179+
180+
const component = mount(
181+
<TagMenu {...createProps({ branching: true })} />
182+
);
183+
const nodes = component.find(
184+
`.${listItemClass}[title*="${TAGS[1].name}"]`
185+
);
186+
nodes.at(0).simulate('click');
187+
188+
expect(spy).toHaveBeenCalledTimes(1);
189+
expect(spy).toHaveBeenCalledWith(TAGS[1].name);
190+
191+
spy.mockRestore();
192+
});
193+
});
194+
195+
describe('create tag', () => {
196+
it('should not allow creating a new tag when branching is disabled', () => {
197+
const spy = jest.spyOn(GitExtension.prototype, 'setTag');
198+
199+
const component = shallow(<TagMenu {...createProps()} />);
200+
201+
const node = component.find('input[type="button"]').first();
202+
node.simulate('click');
203+
204+
expect(component.state('tagDialog')).toEqual(false);
205+
expect(spy).toHaveBeenCalledTimes(0);
206+
spy.mockRestore();
207+
});
208+
209+
it('should display a dialog to create a new tag when branching is enabled and the new tag button is clicked', () => {
210+
const spy = jest.spyOn(GitExtension.prototype, 'setTag');
211+
212+
const component = shallow(
213+
<TagMenu {...createProps({ branching: true })} />
214+
);
215+
216+
const node = component.find('input[type="button"]').first();
217+
node.simulate('click');
218+
219+
expect(component.state('tagDialog')).toEqual(true);
220+
expect(spy).toHaveBeenCalledTimes(0);
221+
spy.mockRestore();
222+
});
223+
});
224+
});

src/__tests__/test-components/Toolbar.spec.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ describe('Toolbar', () => {
4848
tag: ''
4949
}
5050
],
51+
tagsList: model.tagsList,
52+
pastCommits: [],
5153
repository: model.pathRepository,
5254
model: model,
5355
branching: false,

0 commit comments

Comments
 (0)