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,7 +201,9 @@ 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.")
@@ -246,13 +251,17 @@ 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)
@@ -278,7 +287,7 @@ 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)
@@ -428,7 +437,9 @@ def delete_ci_space(space_id: str, pr_num: int) -> None:
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 +450,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 +464,84 @@ 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 +564,17 @@ 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.")
 
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 by 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)
 
287
  # New repo change. Is it a commit on a PR?
288
  # => loop through all PRs and check if new changes happened
289
  for discussion in get_repo_discussions(repo_id=space_id, repo_type="space"):
290
+ if discussion.is_pull_request and discussion.status in ACTIVE_PR_STATUS:
291
  if not is_pr_synced(space_id=space_id, pr_num=discussion.num):
292
  # Found a PR that is not yet synced
293
  task_queue.add_task(sync_ci_space, space_id=space_id, pr_num=discussion.num)
 
437
  def notify_pr(
438
  space_id: str,
439
  pr_num: int,
440
+ action: Literal[
441
+ "created_not_configured", "created_and_configured", "updated", "deleted", "trusted_pr", "untrusted_pr"
442
+ ],
443
  ) -> None:
444
  ci_space_id = _get_ci_space_id(space_id=space_id, pr_num=pr_num)
445
  if action == "created_not_configured":
 
450
  comment = NOTIFICATION_TEMPLATE_UPDATED.format(ci_space_id=ci_space_id)
451
  elif action == "deleted":
452
  comment = NOTIFICATION_TEMPLATE_DELETED
453
+ elif action == "trusted_pr":
454
+ comment = NOTIFICATION_TEMPLATE_TRUSTED_PR
455
+ elif action == "untrusted_pr":
456
+ comment = NOTIFICATION_TEMPLATE_UNTRUSTED_PR
457
  else:
458
  raise ValueError(f"Status {action} not handled.")
459
 
 
464
  return f"{space_id}-ci-pr-{pr_num}"
465
 
466
 
467
+ def set_config(space_id: str, pr_num: str) -> None:
468
+ """a function to set the ephemerial space config"""
469
+ variables: Dict[str, str] = EPHEMERAL_SPACES_CONFIG["variables"]
470
+ secrets: Dict[str, str] = EPHEMERAL_SPACES_CONFIG["secrets"]
471
+ hardware: Optional[SpaceHardware] = EPHEMERAL_SPACES_CONFIG["hardware"]
472
+ storage: Optional[SpaceHardware] = EPHEMERAL_SPACES_CONFIG["storage"]
473
+ ci_space_id = _get_ci_space_id(space_id=space_id, pr_num=pr_num)
474
+ # Configure space
475
+ for key, value in variables.items():
476
+ add_space_variable(ci_space_id, key, value)
477
+ for key, value in secrets.items():
478
+ add_space_secret(ci_space_id, key, value)
479
+
480
+ # Request hardware/storage for space
481
+ if hardware is not None and hardware != SpaceHardware.CPU_BASIC:
482
+ request_space_hardware(ci_space_id, hardware, sleep_time=5 * 60) # sleep after 5min on PR Spaces with GPU
483
+ if storage is not None:
484
+ request_space_storage(ci_space_id, storage)
485
+
486
+
487
+ def rebuild_space(space_id: str, pr_num: int) -> None:
488
+ "a function to rebuild the ephemeral space without config"
489
+ # This is useful to cut down on resource usage and to remove tokens from
490
+ # the ephemeral space
491
+ ci_space_id = _get_ci_space_id(space_id=space_id, pr_num=pr_num)
492
+ try:
493
+ delete_repo(repo_id=ci_space_id, repo_type="space")
494
+ except RepositoryNotFoundError:
495
+ pass
496
+ create_ephemeral_space(space_id=space_id, pr_num=pr_num)
497
+ # Download space codebase from PR revision
498
+ snapshot_path = Path(snapshot_download(repo_id=space_id, revision=f"refs/pr/{pr_num}", repo_type="space"))
499
+
500
+ # Overwrite README file in cache (/!\)
501
+ readme_path = snapshot_path / "README.md"
502
+ card = RepoCard.load(readme_path)
503
+ setattr(card.data, "synced_sha", snapshot_path.name) # latest sha
504
+ card.data.title = f"{card.data.title} (ephemeral #{pr_num})"
505
+ card.save(readme_path)
506
+
507
+ # Sync space codebase with PR revision
508
+ upload_folder(
509
+ repo_id=ci_space_id,
510
+ repo_type="space",
511
+ commit_message=f"Sync CI Space with PR {pr_num}.",
512
+ folder_path=snapshot_path,
513
+ delete_patterns="*",
514
+ )
515
+
516
+ # Delete readme file from cache (just in case)
517
+ readme_path.unlink(missing_ok=True)
518
+
519
+
520
+ def handle_modification(space_id: str, discussion: Any) -> None:
521
+ ci_space_id = _get_ci_space_id(space_id=space_id, pr_num=discussion.num)
522
+ if not repo_exists(ci_space_id):
523
+ return
524
+ details = get_discussion_details(repo_id=space_id, repo_type="space", discussion_num=discussion.num)
525
+ event_author = details.events[-1]._event["author"]["name"] # username of that event
526
+ if event_author not in EPHEMERAL_SPACES_CONFIG["trusted_authors"]:
527
+ # Untrusted author, we rebuild the space
528
+ rebuild_space(space_id=space_id, pr_num=discussion.num)
529
+
530
+
531
+ def handle_command(space_id: str, payload: WebhookPayload) -> None:
532
+ """when a trusted author writes a command we handle it"""
533
+ pr_num = payload.discussion.num
534
+ details = get_discussion_details(repo_id=space_id, repo_type="space", discussion_num=pr_num)
535
+ event_author = details.events[-1]._event["author"]["name"] # username of that event
536
+ if event_author in EPHEMERAL_SPACES_CONFIG["trusted_authors"]:
537
+ if payload.comment.content == "/trust_pr":
538
+ set_config(space_id=space_id, pr_num=pr_num)
539
+ notify_pr(space_id=space_id, pr_num=pr_num, action="trusted_pr")
540
+ elif payload.comment.content == "/untrust_pr":
541
+ rebuild_space(space_id=space_id, pr_num=pr_num)
542
+ notify_pr(space_id=space_id, pr_num=pr_num, action="untrusted_pr")
543
+
544
+
545
  NOTIFICATION_TEMPLATE_CREATED_AND_CONFIGURED = """\
546
  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.
547
  Since this PR has been created by a trusted author, the ephemeral Space has been configured with the correct hardware, storage, and secrets.
 
564
  _(This is an automated message.)_
565
  """
566
 
567
+ NOTIFICATION_TEMPLATE_TRUSTED_PR = """\
568
+ This PR has been granted temporary trust status Thus granting it with the appropriate approriate hardware, storage, and secrets.
569
+ Trust status will be revokeduser either when a trusted author uses `/untrust_pr` command or when new commits are pushed to this PR.
570
+ _(This is an automated message.)_
571
+ """
572
+
573
+ NOTIFICATION_TEMPLATE_UNTRUSTED_PR = """\
574
+ This PR has been untrusted. Thus resetting all hardware, storage, and secrets.
575
+ _(This is an automated message.)_
576
+ """
577
+
578
  ### TO MOVE TO ITS OWN MODULE
579
  # Taken from https://github.com/huggingface/huggingface_hub/issues/1808#issuecomment-1802341663
580