Skip to content

Commit 02276d9

Browse files
committed
convert plane data to grist document
Signed-off-by: Gaëtan Lehmann <[email protected]>
1 parent 60dcc93 commit 02276d9

File tree

6 files changed

+589
-0
lines changed

6 files changed

+589
-0
lines changed

scripts/plane_to_grist/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__pycache__
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.11.11
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
#!/usr/bin/env python3
2+
3+
import argparse
4+
import contextlib
5+
import os
6+
import re
7+
from datetime import datetime
8+
from itertools import islice
9+
10+
import requests
11+
12+
parser = argparse.ArgumentParser(description='Convert plane to grist')
13+
parser.add_argument(
14+
'--plane-token', help="The token used to access the plane api", default=os.environ.get('PLANE_TOKEN')
15+
)
16+
parser.add_argument(
17+
'--grist-token', help="The token used to access the grist api", default=os.environ.get('GRIST_TOKEN')
18+
)
19+
args = parser.parse_args()
20+
21+
PLANE_URL = 'https://project.vates.tech/api/v1/workspaces/vates-global/projects/43438eec-1335-4fc2-8804-5a4c32f4932d'
22+
def get_plane_data(path):
23+
resp = requests.get(f'{PLANE_URL}/{path}', headers={'x-api-key': args.plane_token})
24+
resp.raise_for_status()
25+
data = resp.json()
26+
if 'results' in data:
27+
return data['results']
28+
else:
29+
return data
30+
31+
GRIST_URL = 'https://grist.vates.tech/api/docs/p1ReergFeb75t9oEJQ2XTp'
32+
def grist_get(path):
33+
resp = requests.get(f'{GRIST_URL}/{path}', headers={'Authorization': f'Bearer {args.grist_token}'})
34+
try:
35+
resp.raise_for_status()
36+
except Exception as e:
37+
print(resp.json())
38+
raise e
39+
return resp.json()[path]
40+
41+
def grist_post(path, data):
42+
resp = requests.post(f'{GRIST_URL}/{path}', headers={'Authorization': f'Bearer {args.grist_token}'}, json=data)
43+
try:
44+
resp.raise_for_status()
45+
except Exception as e:
46+
print(resp.json())
47+
raise e
48+
49+
GRIST_TYPES = {
50+
str: 'Text',
51+
int: 'Int',
52+
float: 'Numeric',
53+
bool: 'Bool',
54+
datetime: 'DateTime:Europe/Paris',
55+
}
56+
57+
def convert_type(v):
58+
if isinstance(v, str):
59+
with contextlib.suppress(ValueError):
60+
v = datetime.strptime(v, "%Y-%m-%dT%H:%M:%S.%f%z")
61+
return v
62+
63+
def get_table_columns(data: list[dict]):
64+
res = []
65+
for d in data:
66+
for name, value in d.items():
67+
name = 'id2' if name == 'id' else name
68+
value_type = type(convert_type(value))
69+
if value_type in GRIST_TYPES:
70+
column = {
71+
"id": name,
72+
"fields": {
73+
"type": GRIST_TYPES[value_type],
74+
"label": name,
75+
}
76+
}
77+
if column not in res:
78+
res.append(column)
79+
return res
80+
81+
def filter_columns(d: dict):
82+
res = {}
83+
for name, value in d.items():
84+
name = 'id2' if name == 'id' else name
85+
value_type = type(convert_type(value))
86+
if value_type in GRIST_TYPES:
87+
res[name] = value
88+
return res
89+
90+
def make_chunks(data, size):
91+
it = iter(data)
92+
# use `xragne` if you are in python 2.7:
93+
for i in range(0, len(data), size):
94+
yield [k for k in islice(it, size)]
95+
96+
def table_to_var(table):
97+
return re.sub('([a-z])([A-Z])', r'\1_\2', table).lower()
98+
99+
issues = get_plane_data('issues')
100+
labels = get_plane_data('labels')
101+
modules = get_plane_data('modules')
102+
states = get_plane_data('states')
103+
types = get_plane_data('issue-types')
104+
module_issues = [{
105+
'module': module['id'],
106+
'issue': issue['id']
107+
} for module in modules for issue in get_plane_data(f'modules/{module["id"]}/module-issues')]
108+
members = get_plane_data('members')
109+
110+
# create the tables
111+
tables = grist_get('tables')
112+
existing_tables = set(table['id'] for table in tables)
113+
missing_tables = {'Issues', 'Labels', 'Modules', 'States', 'Types', 'Members'} - existing_tables
114+
for table in missing_tables:
115+
grist_post('tables', {'tables': [{
116+
'id': table,
117+
'columns': get_table_columns(globals()[table_to_var(table)])
118+
}]})
119+
if 'IssueLabels' not in existing_tables:
120+
grist_post('tables', {'tables': [{
121+
'id': 'IssueLabels',
122+
'columns': [
123+
{
124+
"id": 'issue',
125+
"fields": {
126+
"type": 'Ref:Issues',
127+
"label": 'issue',
128+
},
129+
},
130+
{
131+
"id": 'label',
132+
"fields": {
133+
"type": 'Ref:Labels',
134+
"label": 'label',
135+
},
136+
},
137+
]
138+
}]})
139+
if 'IssueAssignees' not in existing_tables:
140+
grist_post('tables', {'tables': [{
141+
'id': 'IssueAssignees',
142+
'columns': [
143+
{
144+
"id": 'issue',
145+
"fields": {
146+
"type": 'Ref:Issues',
147+
"label": 'issue',
148+
},
149+
},
150+
{
151+
"id": 'member',
152+
"fields": {
153+
"type": 'Ref:Members',
154+
"label": 'member',
155+
},
156+
},
157+
]
158+
}]})
159+
if 'ModuleIssues' not in existing_tables:
160+
grist_post('tables', {'tables': [{
161+
'id': 'ModuleIssues',
162+
'columns': [
163+
{
164+
"id": 'module',
165+
"fields": {
166+
"type": 'Ref:Modules',
167+
"label": 'module',
168+
},
169+
},
170+
{
171+
"id": 'issue',
172+
"fields": {
173+
"type": 'Ref:Issues',
174+
"label": 'issue',
175+
},
176+
},
177+
]
178+
}]})
179+
180+
grist_post('tables/Labels/records', {'records': [{'fields': filter_columns(label)} for label in labels]})
181+
grist_post('tables/Modules/records', {'records': [{'fields': filter_columns(module)} for module in modules]})
182+
grist_post('tables/Types/records', {'records': [{'fields': filter_columns(type)} for type in types]})
183+
grist_post('tables/States/records', {'records': [{'fields': filter_columns(state)} for state in states]})
184+
grist_post('tables/Members/records', {'records': [{'fields': filter_columns(member)} for member in members]})
185+
for subissues in make_chunks(issues, 10):
186+
grist_post('tables/Issues/records', {'records': [{'fields': filter_columns(issue)} for issue in subissues]})
187+
issue_label_records = [
188+
{'fields': {'issue': issue['id'], 'label': label}} for issue in subissues for label in issue['labels']
189+
]
190+
if issue_label_records:
191+
grist_post(
192+
'tables/IssueLabels/records',
193+
{
194+
'records': issue_label_records
195+
},
196+
)
197+
issue_assignee_records = [
198+
{'fields': {'issue': issue['id'], 'member': member}} for issue in subissues for member in issue['assignees']
199+
]
200+
if issue_assignee_records:
201+
grist_post(
202+
'tables/IssueAssignees/records',
203+
{
204+
'records': issue_assignee_records
205+
},
206+
)
207+
grist_post(
208+
'tables/ModuleIssues/records',
209+
{'records': [{'fields': filter_columns(module_issue)} for module_issue in module_issues]},
210+
)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
[project]
2+
name = "plane_to_grist"
3+
version = "0.1.0"
4+
description = "Plane to grist converter"
5+
readme = "README.md"
6+
requires-python = "~=3.11"
7+
dependencies = [
8+
"requests",
9+
]
10+
11+
[dependency-groups]
12+
dev = [
13+
"icecream",
14+
"mypy",
15+
"pycodestyle>=2.6.0",
16+
"pydocstyle",
17+
"pyright",
18+
"ruff",
19+
"types-requests",
20+
"typing-extensions",
21+
]
22+
23+
[tool.pyright]
24+
typeCheckingMode = "standard"
25+
26+
[tool.ruff]
27+
preview = true
28+
line-length = 120
29+
exclude = [".git"]
30+
31+
[tool.ruff.format]
32+
quote-style = "preserve"
33+
34+
[tool.ruff.lint]
35+
select = [
36+
"F", # Pyflakes
37+
"I", # isort
38+
"SLF", # flake8-self
39+
"SIM", # flake8-simplify
40+
]
41+
42+
[tool.ruff.lint.isort]
43+
lines-after-imports = 1

scripts/plane_to_grist/setup.cfg

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[pycodestyle]
2+
max-line-length=120
3+
ignore=E261,E302,E305,W503
4+
exclude=data.py,vm_data.py,.git,.venv
5+
6+
[pydocstyle]
7+
ignore=D100,D101,D102,D103,D104,D105,D106,D107,D203,D210,D212,D401,D403

0 commit comments

Comments
 (0)