gitmap-core 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gitmap_core/README.md +46 -0
- gitmap_core/__init__.py +100 -0
- gitmap_core/communication.py +346 -0
- gitmap_core/compat.py +408 -0
- gitmap_core/connection.py +232 -0
- gitmap_core/context.py +709 -0
- gitmap_core/diff.py +283 -0
- gitmap_core/maps.py +385 -0
- gitmap_core/merge.py +449 -0
- gitmap_core/models.py +332 -0
- gitmap_core/py.typed +0 -0
- gitmap_core/pyproject.toml +48 -0
- gitmap_core/remote.py +728 -0
- gitmap_core/repository.py +1632 -0
- gitmap_core/tests/__init__.py +1 -0
- gitmap_core/tests/test_communication.py +695 -0
- gitmap_core/tests/test_compat.py +310 -0
- gitmap_core/tests/test_connection.py +314 -0
- gitmap_core/tests/test_context.py +814 -0
- gitmap_core/tests/test_diff.py +567 -0
- gitmap_core/tests/test_init.py +153 -0
- gitmap_core/tests/test_maps.py +642 -0
- gitmap_core/tests/test_merge.py +694 -0
- gitmap_core/tests/test_models.py +410 -0
- gitmap_core/tests/test_remote.py +3014 -0
- gitmap_core/tests/test_repository.py +1639 -0
- gitmap_core/tests/test_visualize.py +902 -0
- gitmap_core/visualize.py +1217 -0
- gitmap_core-0.1.0.dist-info/METADATA +961 -0
- gitmap_core-0.1.0.dist-info/RECORD +32 -0
- gitmap_core-0.1.0.dist-info/WHEEL +4 -0
- gitmap_core-0.1.0.dist-info/licenses/LICENSE +21 -0
gitmap_core/remote.py
ADDED
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
"""Remote operations module for GitMap.
|
|
2
|
+
|
|
3
|
+
Handles push and pull operations between local repository and
|
|
4
|
+
ArcGIS Portal/AGOL remotes.
|
|
5
|
+
|
|
6
|
+
Execution Context:
|
|
7
|
+
Library module - imported by CLI push/pull commands
|
|
8
|
+
|
|
9
|
+
Dependencies:
|
|
10
|
+
- arcgis: Portal interaction
|
|
11
|
+
- gitmap_core.connection: Portal authentication
|
|
12
|
+
- gitmap_core.models: Data models
|
|
13
|
+
|
|
14
|
+
Metadata:
|
|
15
|
+
Version: 0.1.0
|
|
16
|
+
Author: GitMap Team
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from typing import TYPE_CHECKING
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from gitmap_core.communication import notify_item_group_users
|
|
25
|
+
from gitmap_core.compat import create_folder as compat_create_folder
|
|
26
|
+
from gitmap_core.compat import get_user_folders
|
|
27
|
+
from gitmap_core.connection import PortalConnection
|
|
28
|
+
from gitmap_core.models import Remote
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from arcgis.gis import GIS
|
|
32
|
+
from arcgis.gis import Item
|
|
33
|
+
|
|
34
|
+
from gitmap_core.repository import Repository
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---- Constants ----------------------------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
GITMAP_META_TITLE = ".gitmap_meta"
|
|
41
|
+
GITMAP_FOLDER_SUFFIX = "_GitMap"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---- Remote Operations Class --------------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RemoteOperations:
|
|
48
|
+
"""Handles remote repository operations.
|
|
49
|
+
|
|
50
|
+
Provides methods for pushing branches to Portal and pulling
|
|
51
|
+
updates from Portal.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
repo: Local Repository instance.
|
|
55
|
+
connection: Portal connection.
|
|
56
|
+
config: Repository configuration.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
repo: Repository,
|
|
62
|
+
connection: PortalConnection,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Initialize remote operations.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
repo: Local repository.
|
|
68
|
+
connection: Authenticated Portal connection.
|
|
69
|
+
"""
|
|
70
|
+
self.repo = repo
|
|
71
|
+
self.connection = connection
|
|
72
|
+
self.config = repo.get_config()
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def gis(
|
|
76
|
+
self,
|
|
77
|
+
) -> GIS:
|
|
78
|
+
"""Get GIS connection."""
|
|
79
|
+
return self.connection.gis
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def remote(
|
|
83
|
+
self,
|
|
84
|
+
) -> Remote | None:
|
|
85
|
+
"""Get configured remote."""
|
|
86
|
+
return self.config.remote
|
|
87
|
+
|
|
88
|
+
# ---- Folder Management ----------------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def get_or_create_folder(
|
|
91
|
+
self,
|
|
92
|
+
) -> str:
|
|
93
|
+
"""Get or create the GitMap folder in Portal.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Folder ID.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
RuntimeError: If folder creation fails.
|
|
100
|
+
"""
|
|
101
|
+
folder_name = self.config.project_name
|
|
102
|
+
|
|
103
|
+
# Check if folder_id is already stored in config (from previous push)
|
|
104
|
+
if self.remote and self.remote.folder_id:
|
|
105
|
+
return self.remote.folder_id
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
# Check existing folders first using compat layer
|
|
109
|
+
folders = get_user_folders(self.gis)
|
|
110
|
+
|
|
111
|
+
# Search for existing folder (case-insensitive)
|
|
112
|
+
for folder in folders:
|
|
113
|
+
folder_title = folder.get("title") or ""
|
|
114
|
+
if folder_title.lower() == folder_name.lower():
|
|
115
|
+
folder_id = folder.get("id")
|
|
116
|
+
if folder_id:
|
|
117
|
+
return folder_id
|
|
118
|
+
|
|
119
|
+
# Try to get folder by searching user's content
|
|
120
|
+
# Sometimes folders aren't in user.folders but exist
|
|
121
|
+
user = self.gis.users.me
|
|
122
|
+
try:
|
|
123
|
+
user_content = user.items()
|
|
124
|
+
for item in user_content:
|
|
125
|
+
# Check if item is in a folder with matching name
|
|
126
|
+
item_folder = getattr(item, "ownerFolder", None)
|
|
127
|
+
if item_folder:
|
|
128
|
+
# Get folder info
|
|
129
|
+
folder_info = self.gis.content.get_folder(item_folder, user.username)
|
|
130
|
+
if folder_info:
|
|
131
|
+
folder_title = getattr(folder_info, "title", None) or (folder_info.get("title") if isinstance(folder_info, dict) else None)
|
|
132
|
+
if folder_title == folder_name:
|
|
133
|
+
folder_id = getattr(folder_info, "id", None) or (folder_info.get("id") if isinstance(folder_info, dict) else None)
|
|
134
|
+
if folder_id:
|
|
135
|
+
return folder_id
|
|
136
|
+
except Exception:
|
|
137
|
+
# If folder search fails, continue to creation
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
# Create new folder using compat layer (handles API differences)
|
|
141
|
+
try:
|
|
142
|
+
result = compat_create_folder(self.gis, folder_name)
|
|
143
|
+
if result:
|
|
144
|
+
folder_id = result.get("id", "")
|
|
145
|
+
if folder_id:
|
|
146
|
+
return folder_id
|
|
147
|
+
# If result is falsy or has no id, raise to trigger fallback search
|
|
148
|
+
msg = f"Folder creation returned no ID for '{folder_name}'"
|
|
149
|
+
raise RuntimeError(msg)
|
|
150
|
+
except Exception as create_error:
|
|
151
|
+
# If creation fails because folder exists, search one more time
|
|
152
|
+
error_msg = str(create_error).lower()
|
|
153
|
+
if "not available" in error_msg or "already exists" in error_msg or "unable to create" in error_msg:
|
|
154
|
+
# Folder exists but we didn't find it - search all folders again
|
|
155
|
+
folders = get_user_folders(self.gis)
|
|
156
|
+
for folder in folders:
|
|
157
|
+
folder_title = folder.get("title") or ""
|
|
158
|
+
if folder_title.lower() == folder_name.lower():
|
|
159
|
+
folder_id = folder.get("id")
|
|
160
|
+
if folder_id:
|
|
161
|
+
return folder_id
|
|
162
|
+
|
|
163
|
+
# Try searching through user's items to find the folder
|
|
164
|
+
try:
|
|
165
|
+
user_items = user.items()
|
|
166
|
+
seen_folders: set[str] = set()
|
|
167
|
+
for item in user_items:
|
|
168
|
+
item_folder = getattr(item, "ownerFolder", None)
|
|
169
|
+
if item_folder and item_folder not in seen_folders:
|
|
170
|
+
seen_folders.add(item_folder)
|
|
171
|
+
try:
|
|
172
|
+
folder_obj = self.gis.content.get_folder(item_folder, user.username)
|
|
173
|
+
if folder_obj:
|
|
174
|
+
folder_title = getattr(folder_obj, "title", None)
|
|
175
|
+
if folder_title == folder_name:
|
|
176
|
+
folder_id = getattr(folder_obj, "id", None)
|
|
177
|
+
if folder_id:
|
|
178
|
+
return folder_id
|
|
179
|
+
except Exception:
|
|
180
|
+
continue
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
# If still not found, the folder exists but we can't locate it
|
|
185
|
+
msg = f"Folder '{folder_name}' exists in Portal but could not be located automatically. Please check Portal and update config manually if needed."
|
|
186
|
+
raise RuntimeError(msg) from create_error
|
|
187
|
+
else:
|
|
188
|
+
# Different error - re-raise
|
|
189
|
+
msg = f"Failed to create folder '{folder_name}': {create_error}"
|
|
190
|
+
raise RuntimeError(msg) from create_error
|
|
191
|
+
|
|
192
|
+
except Exception as folder_error:
|
|
193
|
+
msg = f"Folder operation failed: {folder_error}"
|
|
194
|
+
raise RuntimeError(msg) from folder_error
|
|
195
|
+
|
|
196
|
+
# ---- Push Operations ------------------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
def push(
|
|
199
|
+
self,
|
|
200
|
+
branch: str | None = None,
|
|
201
|
+
skip_notifications: bool = False,
|
|
202
|
+
) -> tuple[Item, dict[str, Any]]:
|
|
203
|
+
"""Push branch to Portal.
|
|
204
|
+
|
|
205
|
+
Creates or updates a web map item in the GitMap folder
|
|
206
|
+
representing the specified branch.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
branch: Branch name (defaults to current branch).
|
|
210
|
+
skip_notifications: If True, skip sending notifications even for production branch.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Tuple of (created/updated Portal Item, notification status dict).
|
|
214
|
+
|
|
215
|
+
Raises:
|
|
216
|
+
RuntimeError: If push fails.
|
|
217
|
+
"""
|
|
218
|
+
try:
|
|
219
|
+
branch = branch or self.repo.get_current_branch()
|
|
220
|
+
if not branch:
|
|
221
|
+
msg = "No branch to push (detached HEAD)"
|
|
222
|
+
raise RuntimeError(msg)
|
|
223
|
+
|
|
224
|
+
commit_id = self.repo.get_branch_commit(branch)
|
|
225
|
+
if not commit_id:
|
|
226
|
+
msg = f"Branch '{branch}' has no commits"
|
|
227
|
+
raise RuntimeError(msg)
|
|
228
|
+
|
|
229
|
+
commit = self.repo.get_commit(commit_id)
|
|
230
|
+
if not commit:
|
|
231
|
+
msg = f"Commit '{commit_id}' not found"
|
|
232
|
+
raise RuntimeError(msg)
|
|
233
|
+
|
|
234
|
+
# For main branch, if we have the original item_id, update it directly
|
|
235
|
+
if branch == "main" and self.remote and self.remote.item_id:
|
|
236
|
+
try:
|
|
237
|
+
original_item = self.gis.content.get(self.remote.item_id)
|
|
238
|
+
if original_item and original_item.type == "Web Map":
|
|
239
|
+
updated_item = self._update_webmap_item(original_item, commit.map_data, commit)
|
|
240
|
+
|
|
241
|
+
# Check if this is the production branch and send notifications
|
|
242
|
+
notification_status = {
|
|
243
|
+
"attempted": False,
|
|
244
|
+
"sent": False,
|
|
245
|
+
"reason": "",
|
|
246
|
+
"users_notified": [],
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if not skip_notifications and self.remote.production_branch and branch == self.remote.production_branch:
|
|
250
|
+
notification_status["attempted"] = True
|
|
251
|
+
try:
|
|
252
|
+
# Check if item is shared with groups
|
|
253
|
+
# item.sharing is a SharingManager object, not a dict
|
|
254
|
+
if updated_item.access == "private":
|
|
255
|
+
notification_status["reason"] = "Item is private (not shared)"
|
|
256
|
+
else:
|
|
257
|
+
# Try to get groups from item properties
|
|
258
|
+
groups = []
|
|
259
|
+
try:
|
|
260
|
+
if hasattr(updated_item, "properties") and updated_item.properties:
|
|
261
|
+
sharing_data = updated_item.properties.get("sharing", {})
|
|
262
|
+
if isinstance(sharing_data, dict):
|
|
263
|
+
groups = sharing_data.get("groups", [])
|
|
264
|
+
|
|
265
|
+
# If not found, query user's groups
|
|
266
|
+
if not groups:
|
|
267
|
+
user = self.gis.users.me
|
|
268
|
+
if user:
|
|
269
|
+
user_groups = user.groups
|
|
270
|
+
for group in user_groups:
|
|
271
|
+
try:
|
|
272
|
+
group_items = group.content()
|
|
273
|
+
if any(g_item.id == updated_item.id for g_item in group_items):
|
|
274
|
+
groups.append(group.id)
|
|
275
|
+
except Exception:
|
|
276
|
+
continue
|
|
277
|
+
except Exception:
|
|
278
|
+
groups = []
|
|
279
|
+
|
|
280
|
+
if not groups:
|
|
281
|
+
notification_status["reason"] = "Item is not shared with any groups"
|
|
282
|
+
else:
|
|
283
|
+
# Attempt to send notifications
|
|
284
|
+
notified_users = notify_item_group_users(
|
|
285
|
+
gis=self.gis,
|
|
286
|
+
item=updated_item,
|
|
287
|
+
subject=f"Production Map Updated: {updated_item.title}",
|
|
288
|
+
body=f"The production map '{updated_item.title}' has been updated.\n\n"
|
|
289
|
+
f"Branch: {branch}\n"
|
|
290
|
+
f"Commit: {commit.id[:8]}\n"
|
|
291
|
+
f"Message: {commit.message}\n\n"
|
|
292
|
+
f"View the map: {updated_item.homepage}",
|
|
293
|
+
)
|
|
294
|
+
if notified_users:
|
|
295
|
+
notification_status["sent"] = True
|
|
296
|
+
notification_status["users_notified"] = notified_users
|
|
297
|
+
else:
|
|
298
|
+
notification_status["reason"] = "No users found in groups that have access to the map"
|
|
299
|
+
except Exception as notify_error:
|
|
300
|
+
# Don't fail the push if notifications fail
|
|
301
|
+
notification_status["reason"] = f"Notification error: {notify_error}"
|
|
302
|
+
|
|
303
|
+
return updated_item, notification_status
|
|
304
|
+
except Exception:
|
|
305
|
+
# Original item not found - fall through to folder-based logic
|
|
306
|
+
pass
|
|
307
|
+
|
|
308
|
+
# Check for existing branch item in user's root content (no folder)
|
|
309
|
+
existing_item = self._find_branch_item_in_root(branch)
|
|
310
|
+
|
|
311
|
+
if existing_item:
|
|
312
|
+
# Update existing item
|
|
313
|
+
updated_item = self._update_webmap_item(existing_item, commit.map_data, commit)
|
|
314
|
+
else:
|
|
315
|
+
# Create new item in root content (no folder)
|
|
316
|
+
updated_item = self._create_webmap_item_in_root(branch, commit.map_data, commit)
|
|
317
|
+
|
|
318
|
+
# Check if this is the production branch and send notifications
|
|
319
|
+
notification_status = {
|
|
320
|
+
"attempted": False,
|
|
321
|
+
"sent": False,
|
|
322
|
+
"reason": "",
|
|
323
|
+
"users_notified": [],
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if not skip_notifications and self.remote and self.remote.production_branch and branch == self.remote.production_branch:
|
|
327
|
+
notification_status["attempted"] = True
|
|
328
|
+
try:
|
|
329
|
+
# Check if item is shared with groups
|
|
330
|
+
# item.sharing is a SharingManager object, not a dict
|
|
331
|
+
if updated_item.access == "private":
|
|
332
|
+
notification_status["reason"] = "Item is private (not shared)"
|
|
333
|
+
else:
|
|
334
|
+
# Try to get groups from item properties
|
|
335
|
+
groups = []
|
|
336
|
+
try:
|
|
337
|
+
if hasattr(updated_item, "properties") and updated_item.properties:
|
|
338
|
+
sharing_data = updated_item.properties.get("sharing", {})
|
|
339
|
+
if isinstance(sharing_data, dict):
|
|
340
|
+
groups = sharing_data.get("groups", [])
|
|
341
|
+
|
|
342
|
+
# If not found, query user's groups
|
|
343
|
+
if not groups:
|
|
344
|
+
user = self.gis.users.me
|
|
345
|
+
if user:
|
|
346
|
+
user_groups = user.groups
|
|
347
|
+
for group in user_groups:
|
|
348
|
+
try:
|
|
349
|
+
group_items = group.content()
|
|
350
|
+
if any(g_item.id == updated_item.id for g_item in group_items):
|
|
351
|
+
groups.append(group.id)
|
|
352
|
+
except Exception:
|
|
353
|
+
continue
|
|
354
|
+
except Exception:
|
|
355
|
+
groups = []
|
|
356
|
+
|
|
357
|
+
if not groups:
|
|
358
|
+
notification_status["reason"] = "Item is not shared with any groups"
|
|
359
|
+
else:
|
|
360
|
+
# Attempt to send notifications
|
|
361
|
+
notified_users = notify_item_group_users(
|
|
362
|
+
gis=self.gis,
|
|
363
|
+
item=updated_item,
|
|
364
|
+
subject=f"Production Map Updated: {updated_item.title}",
|
|
365
|
+
body=f"The production map '{updated_item.title}' has been updated.\n\n"
|
|
366
|
+
f"Branch: {branch}\n"
|
|
367
|
+
f"Commit: {commit.id[:8]}\n"
|
|
368
|
+
f"Message: {commit.message}\n\n"
|
|
369
|
+
f"View the map: {updated_item.homepage}",
|
|
370
|
+
)
|
|
371
|
+
if notified_users:
|
|
372
|
+
notification_status["sent"] = True
|
|
373
|
+
notification_status["users_notified"] = notified_users
|
|
374
|
+
else:
|
|
375
|
+
notification_status["reason"] = "No users found in groups that have access to the map"
|
|
376
|
+
except Exception as notify_error:
|
|
377
|
+
# Don't fail the push if notifications fail
|
|
378
|
+
notification_status["reason"] = f"Notification error: {notify_error}"
|
|
379
|
+
|
|
380
|
+
return updated_item, notification_status
|
|
381
|
+
|
|
382
|
+
except Exception as push_error:
|
|
383
|
+
msg = f"Push failed: {push_error}"
|
|
384
|
+
raise RuntimeError(msg) from push_error
|
|
385
|
+
|
|
386
|
+
def _find_branch_item(
|
|
387
|
+
self,
|
|
388
|
+
branch: str,
|
|
389
|
+
folder_id: str,
|
|
390
|
+
) -> Item | None:
|
|
391
|
+
"""Find existing web map item for branch.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
branch: Branch name.
|
|
395
|
+
folder_id: Portal folder ID.
|
|
396
|
+
|
|
397
|
+
Returns:
|
|
398
|
+
Item if found, None otherwise.
|
|
399
|
+
"""
|
|
400
|
+
try:
|
|
401
|
+
user = self.gis.users.me
|
|
402
|
+
items = user.items(folder=folder_id)
|
|
403
|
+
|
|
404
|
+
item_title = self._branch_to_item_title(branch)
|
|
405
|
+
for item in items:
|
|
406
|
+
if item.title == item_title and item.type == "Web Map":
|
|
407
|
+
return item
|
|
408
|
+
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
except Exception:
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
def _branch_to_item_title(
|
|
415
|
+
self,
|
|
416
|
+
branch: str,
|
|
417
|
+
) -> str:
|
|
418
|
+
"""Convert branch name to Portal item title.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
branch: Branch name.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
Sanitized item title.
|
|
425
|
+
"""
|
|
426
|
+
# Replace slashes with underscores for Portal compatibility
|
|
427
|
+
return branch.replace("/", "_")
|
|
428
|
+
|
|
429
|
+
def _find_branch_item_in_root(
|
|
430
|
+
self,
|
|
431
|
+
branch: str,
|
|
432
|
+
) -> Item | None:
|
|
433
|
+
"""Find existing web map item for branch in user's root content.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
branch: Branch name.
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
Item if found, None otherwise.
|
|
440
|
+
"""
|
|
441
|
+
try:
|
|
442
|
+
user = self.gis.users.me
|
|
443
|
+
# Get items from root (no folder)
|
|
444
|
+
items = user.items()
|
|
445
|
+
|
|
446
|
+
item_title = self._branch_to_item_title(branch)
|
|
447
|
+
# Also check with project name prefix for uniqueness
|
|
448
|
+
prefixed_title = f"{self.config.project_name}_{item_title}"
|
|
449
|
+
|
|
450
|
+
for item in items:
|
|
451
|
+
if item.type == "Web Map":
|
|
452
|
+
if item.title == prefixed_title or item.title == item_title:
|
|
453
|
+
# Verify it's a GitMap item by checking tags
|
|
454
|
+
if "GitMap" in (item.tags or []):
|
|
455
|
+
return item
|
|
456
|
+
|
|
457
|
+
return None
|
|
458
|
+
|
|
459
|
+
except Exception:
|
|
460
|
+
return None
|
|
461
|
+
|
|
462
|
+
def _create_webmap_item_in_root(
|
|
463
|
+
self,
|
|
464
|
+
branch: str,
|
|
465
|
+
map_data: dict[str, Any],
|
|
466
|
+
commit: Any,
|
|
467
|
+
) -> Item:
|
|
468
|
+
"""Create new web map item in user's root content (no folder).
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
branch: Branch name.
|
|
472
|
+
map_data: Web map JSON.
|
|
473
|
+
commit: Commit object.
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
Created Item.
|
|
477
|
+
"""
|
|
478
|
+
item_title = self._branch_to_item_title(branch)
|
|
479
|
+
# Prefix with project name to avoid collisions
|
|
480
|
+
prefixed_title = f"{self.config.project_name}_{item_title}"
|
|
481
|
+
|
|
482
|
+
item_properties = {
|
|
483
|
+
"title": prefixed_title,
|
|
484
|
+
"type": "Web Map",
|
|
485
|
+
"tags": ["GitMap", f"project:{self.config.project_name}", f"branch:{branch}", f"commit:{commit.id[:8]}"],
|
|
486
|
+
"description": f"GitMap project: {self.config.project_name}\nBranch: {branch}\nCommit: {commit.id}\n{commit.message}",
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
# Create in root content (no folder parameter)
|
|
490
|
+
item = self.gis.content.add(
|
|
491
|
+
item_properties=item_properties,
|
|
492
|
+
data=json.dumps(map_data),
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
return item
|
|
496
|
+
|
|
497
|
+
def _create_webmap_item(
|
|
498
|
+
self,
|
|
499
|
+
branch: str,
|
|
500
|
+
map_data: dict[str, Any],
|
|
501
|
+
commit: Any,
|
|
502
|
+
folder_id: str,
|
|
503
|
+
) -> Item:
|
|
504
|
+
"""Create new web map item in Portal folder.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
branch: Branch name.
|
|
508
|
+
map_data: Web map JSON.
|
|
509
|
+
commit: Commit object.
|
|
510
|
+
folder_id: Target folder ID.
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
Created Item.
|
|
514
|
+
"""
|
|
515
|
+
item_title = self._branch_to_item_title(branch)
|
|
516
|
+
|
|
517
|
+
item_properties = {
|
|
518
|
+
"title": item_title,
|
|
519
|
+
"type": "Web Map",
|
|
520
|
+
"tags": ["GitMap", f"branch:{branch}", f"commit:{commit.id[:8]}"],
|
|
521
|
+
"description": f"GitMap branch: {branch}\nCommit: {commit.id}\n{commit.message}",
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
# Try new folder-based API first (2.3.0+), fall back to legacy
|
|
525
|
+
try:
|
|
526
|
+
folder = self.gis.content.folders.get(folder_id)
|
|
527
|
+
item = folder.add(
|
|
528
|
+
item_properties=item_properties,
|
|
529
|
+
text=json.dumps(map_data),
|
|
530
|
+
)
|
|
531
|
+
except (AttributeError, TypeError):
|
|
532
|
+
# Fallback for older API versions
|
|
533
|
+
item = self.gis.content.add(
|
|
534
|
+
item_properties=item_properties,
|
|
535
|
+
data=json.dumps(map_data),
|
|
536
|
+
folder=folder_id,
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
return item
|
|
540
|
+
|
|
541
|
+
def _update_webmap_item(
|
|
542
|
+
self,
|
|
543
|
+
item: Item,
|
|
544
|
+
map_data: dict[str, Any],
|
|
545
|
+
commit: Any,
|
|
546
|
+
) -> Item:
|
|
547
|
+
"""Update existing web map item.
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
item: Existing Portal item.
|
|
551
|
+
map_data: New web map JSON.
|
|
552
|
+
commit: Commit object.
|
|
553
|
+
|
|
554
|
+
Returns:
|
|
555
|
+
Updated Item.
|
|
556
|
+
"""
|
|
557
|
+
# Update item properties
|
|
558
|
+
item.update(
|
|
559
|
+
item_properties={
|
|
560
|
+
"tags": item.tags + [f"commit:{commit.id[:8]}"],
|
|
561
|
+
"description": f"GitMap commit: {commit.id}\n{commit.message}",
|
|
562
|
+
},
|
|
563
|
+
data=json.dumps(map_data),
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
return item
|
|
567
|
+
|
|
568
|
+
# ---- Pull Operations ------------------------------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
def pull(
|
|
571
|
+
self,
|
|
572
|
+
branch: str | None = None,
|
|
573
|
+
) -> dict[str, Any]:
|
|
574
|
+
"""Pull latest from Portal.
|
|
575
|
+
|
|
576
|
+
Fetches the web map JSON from Portal for the specified branch
|
|
577
|
+
and updates the local staging area.
|
|
578
|
+
|
|
579
|
+
Args:
|
|
580
|
+
branch: Branch name (defaults to current branch).
|
|
581
|
+
|
|
582
|
+
Returns:
|
|
583
|
+
Pulled map data.
|
|
584
|
+
|
|
585
|
+
Raises:
|
|
586
|
+
RuntimeError: If pull fails.
|
|
587
|
+
"""
|
|
588
|
+
try:
|
|
589
|
+
branch = branch or self.repo.get_current_branch()
|
|
590
|
+
if not branch:
|
|
591
|
+
msg = "No branch to pull (detached HEAD)"
|
|
592
|
+
raise RuntimeError(msg)
|
|
593
|
+
|
|
594
|
+
if not self.remote:
|
|
595
|
+
msg = "No remote configured"
|
|
596
|
+
raise RuntimeError(msg)
|
|
597
|
+
|
|
598
|
+
# For main branch, if we have the original item_id, pull from it directly
|
|
599
|
+
if branch == "main" and self.remote.item_id:
|
|
600
|
+
try:
|
|
601
|
+
original_item = self.gis.content.get(self.remote.item_id)
|
|
602
|
+
if original_item and original_item.type == "Web Map":
|
|
603
|
+
map_data = original_item.get_data()
|
|
604
|
+
if not map_data:
|
|
605
|
+
msg = "Failed to get data from remote item"
|
|
606
|
+
raise RuntimeError(msg)
|
|
607
|
+
|
|
608
|
+
# Update local index
|
|
609
|
+
self.repo.update_index(map_data)
|
|
610
|
+
|
|
611
|
+
# Update remote tracking ref
|
|
612
|
+
self._update_remote_ref(branch, self.repo.get_head_commit() or "")
|
|
613
|
+
|
|
614
|
+
return map_data
|
|
615
|
+
except Exception:
|
|
616
|
+
# Original item not found - fall through to folder-based logic
|
|
617
|
+
pass
|
|
618
|
+
|
|
619
|
+
folder_id = self.remote.folder_id
|
|
620
|
+
if not folder_id:
|
|
621
|
+
msg = "Remote folder not configured"
|
|
622
|
+
raise RuntimeError(msg)
|
|
623
|
+
|
|
624
|
+
# Find branch item
|
|
625
|
+
item = self._find_branch_item(branch, folder_id)
|
|
626
|
+
if not item:
|
|
627
|
+
msg = f"Branch '{branch}' not found in remote"
|
|
628
|
+
raise RuntimeError(msg)
|
|
629
|
+
|
|
630
|
+
# Get map data
|
|
631
|
+
map_data = item.get_data()
|
|
632
|
+
if not map_data:
|
|
633
|
+
msg = f"Failed to get data from remote item"
|
|
634
|
+
raise RuntimeError(msg)
|
|
635
|
+
|
|
636
|
+
# Update local index
|
|
637
|
+
self.repo.update_index(map_data)
|
|
638
|
+
|
|
639
|
+
# Update remote tracking ref
|
|
640
|
+
self._update_remote_ref(branch, self.repo.get_head_commit() or "")
|
|
641
|
+
|
|
642
|
+
return map_data
|
|
643
|
+
|
|
644
|
+
except Exception as pull_error:
|
|
645
|
+
msg = f"Pull failed: {pull_error}"
|
|
646
|
+
raise RuntimeError(msg) from pull_error
|
|
647
|
+
|
|
648
|
+
def _update_remote_ref(
|
|
649
|
+
self,
|
|
650
|
+
branch: str,
|
|
651
|
+
commit_id: str,
|
|
652
|
+
) -> None:
|
|
653
|
+
"""Update remote tracking reference.
|
|
654
|
+
|
|
655
|
+
Args:
|
|
656
|
+
branch: Branch name.
|
|
657
|
+
commit_id: Commit ID.
|
|
658
|
+
"""
|
|
659
|
+
remote_ref_dir = self.repo.remotes_dir / "origin"
|
|
660
|
+
remote_ref_dir.mkdir(parents=True, exist_ok=True)
|
|
661
|
+
|
|
662
|
+
ref_path = remote_ref_dir / branch.replace("/", "_")
|
|
663
|
+
ref_path.write_text(commit_id)
|
|
664
|
+
|
|
665
|
+
# ---- Metadata Operations --------------------------------------------------------------------------------
|
|
666
|
+
|
|
667
|
+
def push_metadata(
|
|
668
|
+
self,
|
|
669
|
+
) -> Item:
|
|
670
|
+
"""Push repository metadata to Portal.
|
|
671
|
+
|
|
672
|
+
Creates/updates the .gitmap_meta item containing branch
|
|
673
|
+
and commit information.
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
Metadata Item.
|
|
677
|
+
"""
|
|
678
|
+
folder_id = self.get_or_create_folder()
|
|
679
|
+
|
|
680
|
+
metadata = {
|
|
681
|
+
"version": "1.0",
|
|
682
|
+
"project_name": self.config.project_name,
|
|
683
|
+
"branches": {},
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
# Collect branch info
|
|
687
|
+
for branch in self.repo.list_branches():
|
|
688
|
+
commit_id = self.repo.get_branch_commit(branch)
|
|
689
|
+
metadata["branches"][branch] = {
|
|
690
|
+
"commit_id": commit_id,
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
# Find or create metadata item
|
|
694
|
+
existing = self._find_metadata_item(folder_id)
|
|
695
|
+
|
|
696
|
+
if existing:
|
|
697
|
+
existing.update(data=json.dumps(metadata))
|
|
698
|
+
return existing
|
|
699
|
+
else:
|
|
700
|
+
item_properties = {
|
|
701
|
+
"title": GITMAP_META_TITLE,
|
|
702
|
+
"type": "Code Attachment",
|
|
703
|
+
"tags": ["GitMap", "metadata"],
|
|
704
|
+
}
|
|
705
|
+
return self.gis.content.add(
|
|
706
|
+
item_properties=item_properties,
|
|
707
|
+
data=json.dumps(metadata),
|
|
708
|
+
folder=folder_id,
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
def _find_metadata_item(
|
|
712
|
+
self,
|
|
713
|
+
folder_id: str,
|
|
714
|
+
) -> Item | None:
|
|
715
|
+
"""Find metadata item in folder."""
|
|
716
|
+
try:
|
|
717
|
+
user = self.gis.users.me
|
|
718
|
+
items = user.items(folder=folder_id)
|
|
719
|
+
|
|
720
|
+
for item in items:
|
|
721
|
+
if item.title == GITMAP_META_TITLE:
|
|
722
|
+
return item
|
|
723
|
+
|
|
724
|
+
return None
|
|
725
|
+
except Exception:
|
|
726
|
+
return None
|
|
727
|
+
|
|
728
|
+
|