add command to deply a pr

#7
README.md CHANGED
@@ -51,7 +51,7 @@ The goal is to improve developer experience by making the review process as lean
51
 
52
  ```py
53
  # requirements.txt
54
- gradio-space-ci @ git+https://huggingface.co/spaces/Wauplin/gradio-space-ci@0.2.2
55
  ```
56
 
57
  2. Set `HF_TOKEN` as a Space secret.
@@ -110,7 +110,7 @@ Add the following line to it:
110
 
111
  ```bash
112
  # requirements.txt
113
- gradio-space-ci @ git+https://huggingface.co/spaces/Wauplin/gradio-space-ci@0.2.2
114
  ```
115
 
116
  ### 2. Add a user token as `HF_TOKEN` secret
 
51
 
52
  ```py
53
  # requirements.txt
54
+ gradio-space-ci @ git+https://huggingface.co/spaces/Wauplin/gradio-space-ci@0.2.3
55
  ```
56
 
57
  2. Set `HF_TOKEN` as a Space secret.
 
110
 
111
  ```bash
112
  # requirements.txt
113
+ gradio-space-ci @ git+https://huggingface.co/spaces/Wauplin/gradio-space-ci@0.2.3
114
  ```
115
 
116
  ### 2. Add a user token as `HF_TOKEN` secret
RELEASE.md CHANGED
@@ -1,3 +1,7 @@
 
 
 
 
1
  ## 0.2.2
2
 
3
  - Install `huggingface_hub>=0.21.1` now that 0.21 have been released.
 
1
+ ## 0.2.3
2
+
3
+ - Deploy on "draft" PRs as well, not just "open" PRs.
4
+
5
  ## 0.2.2
6
 
7
  - Install `huggingface_hub>=0.21.1` now that 0.21 have been released.
open_pr.py CHANGED
@@ -27,7 +27,7 @@ This PR enables Space CI on your Space. **Gradio Space CI is a tool to create ep
27
 
28
  ---
29
  This is an automated PR created with https://huggingface.co/spaces/Wauplin/gradio-space-ci.
30
- For more details about Space CI, checkout [this page]](https://huggingface.co/spaces/Wauplin/gradio-space-ci/blob/main/README.md).
31
  If you find any issues, please report here: https://huggingface.co/spaces/Wauplin/gradio-space-ci/discussions
32
 
33
  Feel free to ignore this PR.
@@ -61,7 +61,7 @@ def open_pr(space_id_or_url: str, oauth_token: gr.OAuthToken | None) -> str:
61
  else:
62
  requirements = ""
63
  if "gradio-space-ci" not in requirements:
64
- requirements += "\ngradio-space-ci @ git+https://huggingface.co/spaces/Wauplin/gradio-space-ci@0.2.2\n"
65
 
66
  # 2. Configure CI in README.md
67
  card = SpaceCard.load(api.hf_hub_download(repo_id=space_id, repo_type="space", filename="README.md"))
 
27
 
28
  ---
29
  This is an automated PR created with https://huggingface.co/spaces/Wauplin/gradio-space-ci.
30
+ For more details about Space CI, checkout [this page](https://huggingface.co/spaces/Wauplin/gradio-space-ci/blob/main/README.md).
31
  If you find any issues, please report here: https://huggingface.co/spaces/Wauplin/gradio-space-ci/discussions
32
 
33
  Feel free to ignore this PR.
 
61
  else:
62
  requirements = ""
63
  if "gradio-space-ci" not in requirements:
64
+ requirements += "\ngradio-space-ci @ git+https://huggingface.co/spaces/Wauplin/gradio-space-ci@0.2.3\n"
65
 
66
  # 2. Configure CI in README.md
67
  card = SpaceCard.load(api.hf_hub_download(repo_id=space_id, repo_type="space", filename="README.md"))
src/gradio_space_ci/__init__.py CHANGED
@@ -38,4 +38,4 @@ else:
38
  from .webhook import enable_space_ci # noqa: F401
39
 
40
 
41
- __version__ = "0.2.2"
 
38
  from .webhook import enable_space_ci # noqa: F401
39
 
40
 
41
+ __version__ = "0.2.3"
src/gradio_space_ci/webhook.py CHANGED
@@ -54,6 +54,9 @@ if SPACE_ID is not None: # If running in a Space (i.e. not locally)
54
 
55
  EPHEMERAL_SPACES_CONFIG: Dict[str, Any] = {}
56
 
 
 
 
57
 
58
  def enable_space_ci() -> None:
59
  """Enable Space CI for the current Space based on config from the README.md file.
@@ -198,17 +201,19 @@ background_pool = ThreadPoolExecutor(max_workers=1)
198
  def recover_after_restart(space_id: str) -> None:
199
  print("Looping through PRs to check if any needs to be synced.")
200
  for discussion in get_repo_discussions(repo_id=space_id, repo_type="space", discussion_type="pull_request"):
201
- if discussion.status == "open":
 
 
202
  if not is_pr_synced(space_id=space_id, pr_num=discussion.num):
203
  # Found a PR that is not yet synced
204
  print(f"Recovery. Found an open PR that is not synced: {discussion.url}. Syncing it.")
205
- background_pool.submit(sync_ci_space, space_id=space_id, pr_num=discussion.num)
206
  if discussion.status == "merged" or discussion.status == "closed":
207
  ci_space_id = _get_ci_space_id(space_id=space_id, pr_num=discussion.num)
208
  if repo_exists(repo_id=ci_space_id, repo_type="space"):
209
  # Found a PR for which the CI space has not been deleted
210
  print(f"Recovery. Found a closed PR with an active CI space: {discussion.url}. Deleting it.")
211
- background_pool.submit(delete_ci_space, space_id=space_id, pr_num=discussion.num)
212
 
213
 
214
  ###
@@ -246,16 +251,20 @@ async def trigger_ci_on_pr(payload: WebhookPayload, task_queue: BackgroundTasks)
246
 
247
  has_task = False
248
  if (
249
- # Means "a new PR has been opened"
250
  payload.event.scope.startswith("discussion")
251
  and payload.event.action == "create"
252
  and payload.discussion is not None
253
  and payload.discussion.isPullRequest
254
- and payload.discussion.status == "open"
255
  ):
 
 
 
 
256
  if not is_pr_synced(space_id=space_id, pr_num=payload.discussion.num):
257
  # New PR! Sync task scheduled
258
- task_queue.add_task(sync_ci_space, space_id=space_id, pr_num=payload.discussion.num)
259
  has_task = True
260
  elif (
261
  # Means "a PR has been merged or closed"
@@ -265,11 +274,7 @@ async def trigger_ci_on_pr(payload: WebhookPayload, task_queue: BackgroundTasks)
265
  and payload.discussion.isPullRequest
266
  and (payload.discussion.status == "merged" or payload.discussion.status == "closed")
267
  ):
268
- task_queue.add_task(
269
- delete_ci_space,
270
- space_id=space_id,
271
- pr_num=payload.discussion.num,
272
- )
273
  has_task = True
274
  elif (
275
  # Means "some content has been pushed to the Space" (any branch)
@@ -278,10 +283,10 @@ async def trigger_ci_on_pr(payload: WebhookPayload, task_queue: BackgroundTasks)
278
  # New repo change. Is it a commit on a PR?
279
  # => loop through all PRs and check if new changes happened
280
  for discussion in get_repo_discussions(repo_id=space_id, repo_type="space"):
281
- if discussion.is_pull_request and discussion.status == "open":
282
  if not is_pr_synced(space_id=space_id, pr_num=discussion.num):
283
  # Found a PR that is not yet synced
284
- task_queue.add_task(sync_ci_space, space_id=space_id, pr_num=discussion.num)
285
  has_task = True
286
 
287
  if has_task:
@@ -312,9 +317,9 @@ def is_pr_synced(space_id: str, pr_num: int) -> bool:
312
  return last_synced_sha == last_pr_sha
313
 
314
 
315
- def sync_ci_space(space_id: str, pr_num: int) -> None:
316
  print(f"New task: sync ephemeral env for {space_id} (PR {pr_num})")
317
- if is_pr_synced(space_id=space_id, pr_num=pr_num):
318
  print("Already synced. Nothing to do.")
319
  return
320
 
@@ -325,11 +330,13 @@ def sync_ci_space(space_id: str, pr_num: int) -> None:
325
 
326
  # Configure ephemeral Space if trusted author
327
  is_configured = False
328
- if is_new:
329
  is_configured = configure_ephemeral_space(space_id=space_id, pr_num=pr_num)
330
 
331
  # Download space codebase from PR revision
332
- snapshot_path = Path(snapshot_download(repo_id=space_id, revision=f"refs/pr/{pr_num}", repo_type="space"))
 
 
333
 
334
  # Overwrite README file in cache (/!\)
335
  readme_path = snapshot_path / "README.md"
@@ -351,7 +358,9 @@ def sync_ci_space(space_id: str, pr_num: int) -> None:
351
  readme_path.unlink(missing_ok=True)
352
 
353
  # Post a comment on the PR
354
- if is_new and is_configured:
 
 
355
  notify_pr(space_id=space_id, pr_num=pr_num, action="created_and_configured")
356
  elif is_new:
357
  notify_pr(space_id=space_id, pr_num=pr_num, action="created_not_configured")
@@ -381,7 +390,14 @@ def create_ephemeral_space(space_id: str, pr_num: int) -> bool:
381
  raise
382
 
383
 
384
- def configure_ephemeral_space(space_id: str, pr_num: int) -> bool:
 
 
 
 
 
 
 
385
  # Config values
386
  ci_space_id = _get_ci_space_id(space_id=space_id, pr_num=pr_num)
387
  trusted_authors: List[str] = EPHEMERAL_SPACES_CONFIG["trusted_authors"]
@@ -392,25 +408,25 @@ def configure_ephemeral_space(space_id: str, pr_num: int) -> bool:
392
 
393
  # Check if trusted author
394
  details = get_discussion_details(repo_id=space_id, repo_type="space", discussion_num=pr_num)
395
- if details.author not in trusted_authors:
396
- return False # not a trusted author => do NOT set secrets, hardware, storage, etc.
397
-
398
- # Configure space
399
- for key, value in variables.items():
400
- add_space_variable(ci_space_id, key, value)
401
- for key, value in secrets.items():
402
- add_space_secret(ci_space_id, key, value)
 
 
 
 
403
 
404
- # Request hardware/storage for space
405
- if hardware is not None and hardware != SpaceHardware.CPU_BASIC:
406
- request_space_hardware(ci_space_id, hardware, sleep_time=5 * 60) # sleep after 5min on PR Spaces with GPU
407
- if storage is not None:
408
- request_space_storage(ci_space_id, storage)
409
-
410
- return True
411
 
412
 
413
- def delete_ci_space(space_id: str, pr_num: int) -> None:
414
  print(f"New task: delete ephemeral env for {space_id} (PR {pr_num})")
415
 
416
  # Delete
@@ -422,13 +438,17 @@ def delete_ci_space(space_id: str, pr_num: int) -> None:
422
  return
423
 
424
  # Notify about deletion
425
- notify_pr(space_id=space_id, pr_num=pr_num, action="deleted")
 
 
426
 
427
 
428
  def notify_pr(
429
  space_id: str,
430
  pr_num: int,
431
- action: Literal["created_not_configured", "created_and_configured", "updated", "deleted"],
 
 
432
  ) -> None:
433
  ci_space_id = _get_ci_space_id(space_id=space_id, pr_num=pr_num)
434
  if action == "created_not_configured":
@@ -439,6 +459,10 @@ def notify_pr(
439
  comment = NOTIFICATION_TEMPLATE_UPDATED.format(ci_space_id=ci_space_id)
440
  elif action == "deleted":
441
  comment = NOTIFICATION_TEMPLATE_DELETED
 
 
 
 
442
  else:
443
  raise ValueError(f"Status {action} not handled.")
444
 
@@ -449,6 +473,42 @@ def _get_ci_space_id(space_id: str, pr_num: int) -> str:
449
  return f"{space_id}-ci-pr-{pr_num}"
450
 
451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
452
  NOTIFICATION_TEMPLATE_CREATED_AND_CONFIGURED = """\
453
  Following the creation of this PR, an ephemeral Space [{ci_space_id}](https://huggingface.co/spaces/{ci_space_id}) has been started. Any changes pushed to this PR will be synced with the test Space.
454
  Since this PR has been created by a trusted author, the ephemeral Space has been configured with the correct hardware, storage, and secrets.
@@ -471,6 +531,19 @@ PR is now merged/closed. The ephemeral Space has been deleted.
471
  _(This is an automated message.)_
472
  """
473
 
 
 
 
 
 
 
 
 
 
 
 
 
 
474
  ### TO MOVE TO ITS OWN MODULE
475
  # Taken from https://github.com/huggingface/huggingface_hub/issues/1808#issuecomment-1802341663
476
 
 
54
 
55
  EPHEMERAL_SPACES_CONFIG: Dict[str, Any] = {}
56
 
57
+ # Draft and open PRs are considered as active (in opposition to closed and merged PRs)
58
+ ACTIVE_PR_STATUS = ("draft", "open")
59
+
60
 
61
  def enable_space_ci() -> None:
62
  """Enable Space CI for the current Space based on config from the README.md file.
 
201
  def recover_after_restart(space_id: str) -> None:
202
  print("Looping through PRs to check if any needs to be synced.")
203
  for discussion in get_repo_discussions(repo_id=space_id, repo_type="space", discussion_type="pull_request"):
204
+ if discussion.status in ACTIVE_PR_STATUS:
205
+ # check pr trust status
206
+ handle_modification(space_id=space_id, discussion=discussion)
207
  if not is_pr_synced(space_id=space_id, pr_num=discussion.num):
208
  # Found a PR that is not yet synced
209
  print(f"Recovery. Found an open PR that is not synced: {discussion.url}. Syncing it.")
210
+ background_pool.submit(sync_ci_space, space_id=space_id, pr_num=discussion.num, skip_config=False)
211
  if discussion.status == "merged" or discussion.status == "closed":
212
  ci_space_id = _get_ci_space_id(space_id=space_id, pr_num=discussion.num)
213
  if repo_exists(repo_id=ci_space_id, repo_type="space"):
214
  # Found a PR for which the CI space has not been deleted
215
  print(f"Recovery. Found a closed PR with an active CI space: {discussion.url}. Deleting it.")
216
+ background_pool.submit(delete_ci_space, space_id=space_id, pr_num=discussion.num, notify=True)
217
 
218
 
219
  ###
 
251
 
252
  has_task = False
253
  if (
254
+ # Means "a new PR has been opened" or a new comment
255
  payload.event.scope.startswith("discussion")
256
  and payload.event.action == "create"
257
  and payload.discussion is not None
258
  and payload.discussion.isPullRequest
259
+ and payload.discussion.status in ACTIVE_PR_STATUS
260
  ):
261
+ # A comment, is it a command ?
262
+ if payload.event.scope == "discussion.comment":
263
+ handle_command(space_id=space_id, payload=payload)
264
+ # Always sync (in case the space was sleeping or building)
265
  if not is_pr_synced(space_id=space_id, pr_num=payload.discussion.num):
266
  # New PR! Sync task scheduled
267
+ task_queue.add_task(sync_ci_space, space_id=space_id, pr_num=payload.discussion.num, skip_config=False)
268
  has_task = True
269
  elif (
270
  # Means "a PR has been merged or closed"
 
274
  and payload.discussion.isPullRequest
275
  and (payload.discussion.status == "merged" or payload.discussion.status == "closed")
276
  ):
277
+ task_queue.add_task(delete_ci_space, space_id=space_id, pr_num=payload.discussion.num, notify=True)
 
 
 
 
278
  has_task = True
279
  elif (
280
  # Means "some content has been pushed to the Space" (any branch)
 
283
  # New repo change. Is it a commit on a PR?
284
  # => loop through all PRs and check if new changes happened
285
  for discussion in get_repo_discussions(repo_id=space_id, repo_type="space"):
286
+ if discussion.is_pull_request and discussion.status in ACTIVE_PR_STATUS:
287
  if not is_pr_synced(space_id=space_id, pr_num=discussion.num):
288
  # Found a PR that is not yet synced
289
+ task_queue.add_task(sync_ci_space, space_id=space_id, pr_num=discussion.num, skip_config=False)
290
  has_task = True
291
 
292
  if has_task:
 
317
  return last_synced_sha == last_pr_sha
318
 
319
 
320
+ def sync_ci_space(space_id: str, pr_num: int, skip_config: bool = False) -> None:
321
  print(f"New task: sync ephemeral env for {space_id} (PR {pr_num})")
322
+ if is_pr_synced(space_id=space_id, pr_num=pr_num) and not skip_config:
323
  print("Already synced. Nothing to do.")
324
  return
325
 
 
330
 
331
  # Configure ephemeral Space if trusted author
332
  is_configured = False
333
+ if not skip_config and is_new:
334
  is_configured = configure_ephemeral_space(space_id=space_id, pr_num=pr_num)
335
 
336
  # Download space codebase from PR revision
337
+ snapshot_path = Path(
338
+ snapshot_download(repo_id=space_id, revision=f"refs/pr/{pr_num}", repo_type="space", force_download=True)
339
+ )
340
 
341
  # Overwrite README file in cache (/!\)
342
  readme_path = snapshot_path / "README.md"
 
358
  readme_path.unlink(missing_ok=True)
359
 
360
  # Post a comment on the PR
361
+ if is_new and skip_config:
362
+ notify_pr(space_id=space_id, pr_num=pr_num, action="untrusted_pr")
363
+ elif is_new and is_configured:
364
  notify_pr(space_id=space_id, pr_num=pr_num, action="created_and_configured")
365
  elif is_new:
366
  notify_pr(space_id=space_id, pr_num=pr_num, action="created_not_configured")
 
390
  raise
391
 
392
 
393
+ ##
394
+ # configure_ephemeral_space logic
395
+ # if a pr is made by a trusted author or an author trusts a pr
396
+ # => we configure the ephemeral space
397
+ ##
398
+
399
+
400
+ def configure_ephemeral_space(space_id: str, pr_num: int, trusted_pr=False) -> bool:
401
  # Config values
402
  ci_space_id = _get_ci_space_id(space_id=space_id, pr_num=pr_num)
403
  trusted_authors: List[str] = EPHEMERAL_SPACES_CONFIG["trusted_authors"]
 
408
 
409
  # Check if trusted author
410
  details = get_discussion_details(repo_id=space_id, repo_type="space", discussion_num=pr_num)
411
+ if details.author in trusted_authors or trusted_pr is True:
412
+ # Configure space
413
+ for key, value in variables.items():
414
+ add_space_variable(ci_space_id, key, value)
415
+ for key, value in secrets.items():
416
+ add_space_secret(ci_space_id, key, value)
417
+
418
+ # Request hardware/storage for space
419
+ if hardware is not None and hardware != SpaceHardware.CPU_BASIC:
420
+ request_space_hardware(ci_space_id, hardware, sleep_time=5 * 60) # sleep after 5min on PR Spaces with GPU
421
+ if storage is not None:
422
+ request_space_storage(ci_space_id, storage)
423
 
424
+ return True
425
+ else:
426
+ return False
 
 
 
 
427
 
428
 
429
+ def delete_ci_space(space_id: str, pr_num: int, notify: bool = True) -> None:
430
  print(f"New task: delete ephemeral env for {space_id} (PR {pr_num})")
431
 
432
  # Delete
 
438
  return
439
 
440
  # Notify about deletion
441
+ if notify is True:
442
+ # This logic is adaped across multiple functions so we will not always notify the pr
443
+ notify_pr(space_id=space_id, pr_num=pr_num, action="deleted")
444
 
445
 
446
  def notify_pr(
447
  space_id: str,
448
  pr_num: int,
449
+ action: Literal[
450
+ "created_not_configured", "created_and_configured", "updated", "deleted", "trusted_pr", "untrusted_pr"
451
+ ],
452
  ) -> None:
453
  ci_space_id = _get_ci_space_id(space_id=space_id, pr_num=pr_num)
454
  if action == "created_not_configured":
 
459
  comment = NOTIFICATION_TEMPLATE_UPDATED.format(ci_space_id=ci_space_id)
460
  elif action == "deleted":
461
  comment = NOTIFICATION_TEMPLATE_DELETED
462
+ elif action == "trusted_pr":
463
+ comment = NOTIFICATION_TEMPLATE_TRUSTED_PR.format(ci_space_id=ci_space_id)
464
+ elif action == "untrusted_pr":
465
+ comment = NOTIFICATION_TEMPLATE_UNTRUSTED_PR.format(ci_space_id=ci_space_id)
466
  else:
467
  raise ValueError(f"Status {action} not handled.")
468
 
 
473
  return f"{space_id}-ci-pr-{pr_num}"
474
 
475
 
476
+ def rebuild_space(space_id: str, pr_num: int) -> None:
477
+ "a function to rebuild the ephemeral space without config"
478
+ # This is useful to cut down on resource usage and to remove tokens from
479
+ # the ephemeral space
480
+ delete_ci_space(space_id=space_id, pr_num=pr_num, notify=False)
481
+ # create a new synced ephemeral space
482
+ sync_ci_space(space_id=space_id, pr_num=pr_num, skip_config=True)
483
+
484
+
485
+ def handle_modification(space_id: str, discussion: Any) -> None:
486
+ ci_space_id = _get_ci_space_id(space_id=space_id, pr_num=discussion.num)
487
+ if not repo_exists(ci_space_id):
488
+ return
489
+ details = get_discussion_details(repo_id=space_id, repo_type="space", discussion_num=discussion.num)
490
+ # If last commit is not by a trusted author we rebuild it
491
+ for event in details.events[::-1]:
492
+ if event.type == "commit":
493
+ if event._event["author"]["name"] not in EPHEMERAL_SPACES_CONFIG["trusted_authors"]:
494
+ rebuild_space(space_id=space_id, pr_num=discussion.num)
495
+ else:
496
+ return
497
+
498
+
499
+ def handle_command(space_id: str, payload: WebhookPayload) -> None:
500
+ """when a trusted author writes a command we handle it"""
501
+ pr_num = payload.discussion.num
502
+ details = get_discussion_details(repo_id=space_id, repo_type="space", discussion_num=pr_num)
503
+ event_author = details.events[-1]._event["author"]["name"] # username of that event
504
+ if event_author in EPHEMERAL_SPACES_CONFIG["trusted_authors"]:
505
+ if payload.comment.content == "/trust_pr":
506
+ configure_ephemeral_space(space_id=space_id, pr_num=pr_num, trusted_pr=True)
507
+ notify_pr(space_id=space_id, pr_num=pr_num, action="trusted_pr")
508
+ elif payload.comment.content == "/untrust_pr":
509
+ rebuild_space(space_id=space_id, pr_num=pr_num)
510
+
511
+
512
  NOTIFICATION_TEMPLATE_CREATED_AND_CONFIGURED = """\
513
  Following the creation of this PR, an ephemeral Space [{ci_space_id}](https://huggingface.co/spaces/{ci_space_id}) has been started. Any changes pushed to this PR will be synced with the test Space.
514
  Since this PR has been created by a trusted author, the ephemeral Space has been configured with the correct hardware, storage, and secrets.
 
531
  _(This is an automated message.)_
532
  """
533
 
534
+ NOTIFICATION_TEMPLATE_TRUSTED_PR = """\
535
+ This PR has been granted temporary trust status Thus granting it with the appropriate approriate hardware, storage, and secrets.
536
+ You can access the ephemeral Space at [{ci_space_id}](https://huggingface.co/spaces/{ci_space_id}).
537
+ Trust status will be revoked either when a trusted author uses `/untrust_pr` command or when new commits are pushed to this PR.
538
+ _(This is an automated message.)_
539
+ """
540
+
541
+ NOTIFICATION_TEMPLATE_UNTRUSTED_PR = """\
542
+ This PR has been untrusted. Thus resetting all hardware, storage, and secrets.
543
+ You can access this PR's ephemeral Space at [{ci_space_id}](https://huggingface.co/spaces/{ci_space_id}).
544
+ _(This is an automated message.)_
545
+ """
546
+
547
  ### TO MOVE TO ITS OWN MODULE
548
  # Taken from https://github.com/huggingface/huggingface_hub/issues/1808#issuecomment-1802341663
549