Skip to content

Commit 4b011af

Browse files
authored
Merge pull request #17 from feature/artifact-download
Add workflow-run artifact listing and download
2 parents 991e523 + 86037dc commit 4b011af

6 files changed

Lines changed: 643 additions & 4 deletions

File tree

GUI/actions.py

Lines changed: 315 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
import webbrowser
55
import platform
66
import threading
7+
import os
78
from application import get_app
89
from models.repository import Repository
9-
from models.workflow import Workflow, WorkflowRun, WorkflowJob
10+
from models.workflow import Workflow, WorkflowRun, WorkflowJob, Artifact
1011
from . import theme
1112

1213

@@ -430,6 +431,9 @@ def init_ui(self):
430431
self.open_job_btn = wx.Button(self.panel, label="Open &Job in Browser")
431432
btn_sizer1.Add(self.open_job_btn, 0, wx.RIGHT, 5)
432433

434+
self.artifacts_btn = wx.Button(self.panel, label="&Artifacts...")
435+
btn_sizer1.Add(self.artifacts_btn, 0, wx.RIGHT, 5)
436+
433437
self.close_btn = wx.Button(self.panel, wx.ID_CLOSE, label="Cl&ose")
434438
btn_sizer1.Add(self.close_btn, 0)
435439

@@ -526,6 +530,7 @@ def bind_events(self):
526530
self.cancel_btn.Bind(wx.EVT_BUTTON, self.on_cancel)
527531
self.open_browser_btn.Bind(wx.EVT_BUTTON, self.on_open_browser)
528532
self.open_job_btn.Bind(wx.EVT_BUTTON, self.on_open_job)
533+
self.artifacts_btn.Bind(wx.EVT_BUTTON, self.on_view_artifacts)
529534
self.close_btn.Bind(wx.EVT_BUTTON, self.on_close)
530535
self.jobs_list.Bind(wx.EVT_LISTBOX, self.on_job_selection_change)
531536
self.jobs_list.Bind(wx.EVT_LISTBOX_DCLICK, self.on_view_logs)
@@ -655,6 +660,315 @@ def on_view_logs(self, event):
655660
dlg.ShowModal()
656661
dlg.Destroy()
657662

663+
def on_view_artifacts(self, event):
664+
"""View and download artifacts produced by this run."""
665+
dlg = ArtifactsDialog(self, self.repo, self.run)
666+
dlg.ShowModal()
667+
dlg.Destroy()
668+
669+
def on_close(self, event):
670+
"""Close dialog."""
671+
self.EndModal(wx.ID_CLOSE)
672+
673+
674+
class ArtifactsDialog(wx.Dialog):
675+
"""Dialog for viewing and downloading a workflow run's artifacts."""
676+
677+
def __init__(self, parent, repo: Repository, run: WorkflowRun):
678+
self.repo = repo
679+
self.run = run
680+
self.app = get_app()
681+
self.account = self.app.currentAccount
682+
self.artifacts = []
683+
684+
title = f"Artifacts - Run #{run.run_number}"
685+
wx.Dialog.__init__(self, parent, title=title, size=(700, 500))
686+
687+
self.init_ui()
688+
self.bind_events()
689+
theme.apply_theme(self)
690+
691+
# Load artifacts
692+
self.load_artifacts()
693+
694+
def init_ui(self):
695+
"""Initialize the UI."""
696+
self.panel = wx.Panel(self)
697+
main_sizer = wx.BoxSizer(wx.VERTICAL)
698+
699+
# Artifacts list
700+
list_label = wx.StaticText(self.panel, label="&Artifacts:")
701+
main_sizer.Add(list_label, 0, wx.LEFT | wx.TOP, 10)
702+
703+
self.artifacts_list = wx.ListBox(self.panel, style=wx.LB_SINGLE)
704+
main_sizer.Add(self.artifacts_list, 1, wx.EXPAND | wx.ALL, 10)
705+
706+
# Buttons
707+
btn_sizer = wx.BoxSizer(wx.HORIZONTAL)
708+
709+
self.download_btn = wx.Button(self.panel, label="&Download Selected")
710+
btn_sizer.Add(self.download_btn, 0, wx.RIGHT, 5)
711+
712+
self.download_all_btn = wx.Button(self.panel, label="Download A&ll")
713+
btn_sizer.Add(self.download_all_btn, 0, wx.RIGHT, 5)
714+
715+
self.refresh_btn = wx.Button(self.panel, label="&Refresh")
716+
btn_sizer.Add(self.refresh_btn, 0, wx.RIGHT, 5)
717+
718+
self.open_browser_btn = wx.Button(self.panel, label="Open Run in &Browser")
719+
btn_sizer.Add(self.open_browser_btn, 0, wx.RIGHT, 5)
720+
721+
self.close_btn = wx.Button(self.panel, wx.ID_CLOSE, label="Cl&ose")
722+
btn_sizer.Add(self.close_btn, 0)
723+
724+
main_sizer.Add(btn_sizer, 0, wx.ALL | wx.ALIGN_CENTER, 10)
725+
726+
self.panel.SetSizer(main_sizer)
727+
728+
def bind_events(self):
729+
"""Bind event handlers."""
730+
self.Bind(wx.EVT_CLOSE, self.on_close)
731+
self.Bind(wx.EVT_CHAR_HOOK, self.on_char_hook)
732+
self.download_btn.Bind(wx.EVT_BUTTON, self.on_download)
733+
self.download_all_btn.Bind(wx.EVT_BUTTON, self.on_download_all)
734+
self.refresh_btn.Bind(wx.EVT_BUTTON, self.on_refresh)
735+
self.open_browser_btn.Bind(wx.EVT_BUTTON, self.on_open_browser)
736+
self.close_btn.Bind(wx.EVT_BUTTON, self.on_close)
737+
self.artifacts_list.Bind(wx.EVT_LISTBOX, self.on_selection_change)
738+
self.artifacts_list.Bind(wx.EVT_LISTBOX_DCLICK, self.on_download)
739+
self.artifacts_list.Bind(wx.EVT_KEY_DOWN, self.on_key)
740+
741+
def on_char_hook(self, event):
742+
"""Handle key events."""
743+
if event.GetKeyCode() == wx.WXK_ESCAPE:
744+
self.on_close(None)
745+
else:
746+
event.Skip()
747+
748+
def load_artifacts(self):
749+
"""Load artifacts in background."""
750+
self.artifacts_list.Clear()
751+
self.artifacts_list.Append("Loading artifacts...")
752+
self.artifacts = []
753+
self.update_buttons()
754+
755+
def do_load():
756+
artifacts = self.account.get_run_artifacts(
757+
self.repo.owner, self.repo.name, self.run.id
758+
)
759+
wx.CallAfter(self.update_artifacts_list, artifacts)
760+
761+
threading.Thread(target=do_load, daemon=True).start()
762+
763+
def update_artifacts_list(self, artifacts):
764+
"""Update the artifacts list."""
765+
self.artifacts = artifacts
766+
self.artifacts_list.Clear()
767+
768+
if not artifacts:
769+
self.artifacts_list.Append("No artifacts found")
770+
else:
771+
for artifact in artifacts:
772+
self.artifacts_list.Append(artifact.format_display())
773+
774+
self.update_buttons()
775+
776+
def update_buttons(self):
777+
"""Update button states based on selection."""
778+
artifact = self.get_selected_artifact()
779+
can_download = artifact is not None and not artifact.expired
780+
781+
self.download_btn.Enable(can_download)
782+
# Download-all is useful only if at least one artifact is downloadable
783+
self.download_all_btn.Enable(any(not a.expired for a in self.artifacts))
784+
785+
def get_selected_artifact(self) -> Artifact | None:
786+
"""Get the currently selected artifact."""
787+
selection = self.artifacts_list.GetSelection()
788+
if selection != wx.NOT_FOUND and selection < len(self.artifacts):
789+
return self.artifacts[selection]
790+
return None
791+
792+
def on_selection_change(self, event):
793+
"""Handle selection change."""
794+
self.update_buttons()
795+
796+
def on_refresh(self, event):
797+
"""Refresh the artifacts list."""
798+
self.load_artifacts()
799+
800+
def _download_dir(self) -> str | None:
801+
"""Return the download directory, creating it if needed. None on failure."""
802+
download_dir = self.app.prefs.download_location
803+
if not os.path.exists(download_dir):
804+
try:
805+
os.makedirs(download_dir)
806+
except Exception as e:
807+
wx.MessageBox(
808+
f"Could not create download directory:\n{e}",
809+
"Error",
810+
wx.OK | wx.ICON_ERROR
811+
)
812+
return None
813+
return download_dir
814+
815+
def _dest_path(self, download_dir: str, artifact: Artifact) -> str:
816+
"""Artifacts are delivered as zips; save them with a .zip extension.
817+
818+
The name comes from the API, so strip any path components to keep the
819+
file inside the chosen download directory.
820+
"""
821+
name = os.path.basename(artifact.name)
822+
if not name.lower().endswith(".zip"):
823+
name = f"{name}.zip"
824+
return os.path.join(download_dir, name)
825+
826+
def on_download(self, event):
827+
"""Download the selected artifact."""
828+
artifact = self.get_selected_artifact()
829+
if not artifact:
830+
return
831+
832+
if artifact.expired:
833+
wx.MessageBox(
834+
"This artifact has expired and can no longer be downloaded.",
835+
"Artifact Expired",
836+
wx.OK | wx.ICON_WARNING
837+
)
838+
return
839+
840+
download_dir = self._download_dir()
841+
if download_dir is None:
842+
return
843+
844+
dest_path = self._dest_path(download_dir, artifact)
845+
846+
if os.path.exists(dest_path):
847+
result = wx.MessageBox(
848+
f"File already exists:\n{dest_path}\n\nOverwrite?",
849+
"File Exists",
850+
wx.YES_NO | wx.ICON_QUESTION
851+
)
852+
if result != wx.YES:
853+
return
854+
855+
# A progress dialog so large artifacts don't look frozen while they
856+
# stream (the win-x64 builds are ~90 MB).
857+
progress = wx.ProgressDialog(
858+
"Downloading Artifact",
859+
f"Downloading {artifact.name}...",
860+
maximum=100,
861+
parent=self,
862+
style=wx.PD_APP_MODAL | wx.PD_AUTO_HIDE | wx.PD_ELAPSED_TIME
863+
)
864+
state = {"done": False}
865+
866+
def progress_cb(downloaded, total):
867+
def update():
868+
if state["done"]:
869+
return
870+
if total:
871+
progress.Update(min(int(downloaded * 100 / total), 100))
872+
else:
873+
progress.Pulse()
874+
wx.CallAfter(update)
875+
876+
def do_download():
877+
success = self.account.download_artifact(
878+
self.repo.owner, self.repo.name, artifact.id, dest_path,
879+
progress_callback=progress_cb
880+
)
881+
wx.CallAfter(download_complete, success)
882+
883+
def download_complete(success):
884+
state["done"] = True
885+
progress.Destroy()
886+
if success:
887+
wx.MessageBox(
888+
f"Downloaded:\n{dest_path}",
889+
"Download Complete",
890+
wx.OK | wx.ICON_INFORMATION
891+
)
892+
else:
893+
wx.MessageBox(
894+
f"Failed to download {artifact.name}.",
895+
"Error",
896+
wx.OK | wx.ICON_ERROR
897+
)
898+
899+
threading.Thread(target=do_download, daemon=True).start()
900+
901+
def on_download_all(self, event):
902+
"""Download all non-expired artifacts in the background."""
903+
downloadable = [a for a in self.artifacts if not a.expired]
904+
if not downloadable:
905+
return
906+
907+
result = wx.MessageBox(
908+
f"Download {len(downloadable)} artifact(s) in background?",
909+
"Confirm Download",
910+
wx.YES_NO | wx.ICON_QUESTION
911+
)
912+
if result != wx.YES:
913+
return
914+
915+
download_dir = self._download_dir()
916+
if download_dir is None:
917+
return
918+
919+
# Warn once if any of the targets already exist, matching the
920+
# single-download and release-asset flows.
921+
existing = [a for a in downloadable
922+
if os.path.exists(self._dest_path(download_dir, a))]
923+
if existing:
924+
result = wx.MessageBox(
925+
f"{len(existing)} file(s) already exist. Overwrite?",
926+
"Files Exist",
927+
wx.YES_NO | wx.ICON_QUESTION
928+
)
929+
if result != wx.YES:
930+
return
931+
932+
def do_download_all():
933+
succeeded = 0
934+
failed = 0
935+
for artifact in downloadable:
936+
dest_path = self._dest_path(download_dir, artifact)
937+
if self.account.download_artifact(
938+
self.repo.owner, self.repo.name, artifact.id, dest_path
939+
):
940+
succeeded += 1
941+
else:
942+
failed += 1
943+
wx.CallAfter(download_all_complete, succeeded, failed)
944+
945+
def download_all_complete(succeeded, failed):
946+
if failed == 0:
947+
wx.MessageBox(
948+
f"Downloaded {succeeded} artifact(s) to:\n{download_dir}",
949+
"Download Complete",
950+
wx.OK | wx.ICON_INFORMATION
951+
)
952+
else:
953+
wx.MessageBox(
954+
f"Downloaded {succeeded} artifact(s), {failed} failed.\n\nLocation: {download_dir}",
955+
"Download Complete",
956+
wx.OK | wx.ICON_WARNING
957+
)
958+
959+
threading.Thread(target=do_download_all, daemon=True).start()
960+
961+
def on_open_browser(self, event):
962+
"""Open the workflow run in the browser."""
963+
webbrowser.open(self.run.html_url)
964+
965+
def on_key(self, event):
966+
"""Handle key events."""
967+
if event.GetKeyCode() == wx.WXK_RETURN:
968+
self.on_download(None)
969+
else:
970+
event.Skip()
971+
658972
def on_close(self, event):
659973
"""Close dialog."""
660974
self.EndModal(wx.ID_CLOSE)

0 commit comments

Comments
 (0)