Github Issues auswerten

Wenn du dir nicht sicher bist, in welchem der anderen Foren du die Frage stellen sollst, dann bist du hier im Forum für allgemeine Fragen sicher richtig.
Antworten
Benutzeravatar
Kebap
User
Beiträge: 687
Registriert: Dienstag 15. November 2011, 14:20
Wohnort: Dortmund

Hallo zusammen,

für ein Projekt soll regelmäßig ausgewertet werden, welche Issues in einem Github Repo bestimmte Labels tragen, wann die gesetzt und entfernt wurden, usw.

Mein Ansatz wäre, dies über Github Actions zu realisieren, weil die automatisch angestoßen werden können, sobald irgendein Issue irgendein Label erhält.

Im Folgenden würde ich dann ein Python Skript laufen lassen, das die ganze detaillierte Logik enthält und die relevanten Daten zusammenfasst und aufbereitet.

Zur Anzeige und Speicherung der Daten bin ich noch unentschlossen. Vielleicht schreibe ich einfach alles in ein Google Docs? Das wäre ggf. für manuelle Bearbeitung praktisch.

Alternativ könnte ich mir die Schnittstelle sparen, sondern die Daten strukturiert ins Repo speichern, und außerdem direkt bei Github Pages als Webseite darstellen und hosten lassen.

Jetzt kenne ich mich nicht so gut aus, welche Python Bibliotheken sich da so anbieten, um tabellarische Daten vernünftig anzuzeigen. Pages kann wohl nur normales HTML / CSS / JS.

Hat jemand schon mal ähnliche Projekte gehabt und wie würdet ihr vorgehen? :mrgreen:
MorgenGrauen: 1 Welt, 8 Rassen, 13 Gilden, >250 Abenteuer, >5000 Waffen & Rüstungen,
>7000 NPC, >16000 Räume, >200 freiwillige Programmierer, nur Text, viel Spaß, seit 1992.
Benutzeravatar
Kebap
User
Beiträge: 687
Registriert: Dienstag 15. November 2011, 14:20
Wohnort: Dortmund

Ich versuche jetzt, die relevanten Informationen einfach per Github API zu sammeln und benutze dafür das Paket PyGithub. Gefällt mir bisher sehr gut!
MorgenGrauen: 1 Welt, 8 Rassen, 13 Gilden, >250 Abenteuer, >5000 Waffen & Rüstungen,
>7000 NPC, >16000 Räume, >200 freiwillige Programmierer, nur Text, viel Spaß, seit 1992.
Benutzeravatar
Kebap
User
Beiträge: 687
Registriert: Dienstag 15. November 2011, 14:20
Wohnort: Dortmund

Das mit den Labels ist nicht das eigentliche Ziel, sondern nur ein hoffentlich ausreichender Weg dorthin.
Eigentlich soll ausgewertet werden, wieviel Geld wann für Bounties auf einzelne Issues ausgegeben wurde.

Dabei gibt es einige interessante Labels, bspw. "bounty-20", usw. wenn 20 Dollar an Bounty ausgelobt wurden, und "bounty-paid", wenn das jemand erfolgreich erfüllt hatte.
Der Prozess ist also meist: Issues identifizieren, die zum Bounty werden sollen, Label "bounty-100" osä. dran pappen, warten, Ergebnis prüfen, dann ggf. "bounty-paid" dran.
Manchmal wartet man aber so lange, und niemand erfüllt die Aufgabe. Dann kann es sein, dass der Bounty nachträglich noch erhöht wird, bspw. von 20 auf 100 Dollar.
Für die Labels bedeutet das, dass "bounty-20" entfernt und "bounty-100" ergänzt wurde. All das kann ich mit der Github API gut auslesen, wann welches Label dran kam.
Ein bisschen habe ich gerätselt, wie ich darstellen soll, dass ein Bounty über seine Lebenszeit mehrere Höhen inne hatte. Für mich sind das nun einfach 2 verschiedene Bounties.
Um die ganzen Daten eines Bounty zu verwalten, habe ich eine Klasse erstellt. Damit die auch ins Set passt, mussten einige __ Funktionen her. Das hab ich noch nie gemacht. :mrgreen:

Die Schnittstelle zu Google Sheets habe ich nicht implementiert, bzw. wird vom Code einfach eine quasi Tabelle als Text ausgegeben und dann per Copy/Paste weitertransportiert.
Ein Stück weit ist unklar, ob ich den Code jetzt weiter erweitern kann und soll, um nicht nur historische sondern auch neue Daten zukünftig zu sammeln und zu ergänzen.
Hintergrund ist, dass die Bounties nun von Algora Bot verwaltet werden, und dieser fügt nur immer dasselbe Label "Bounty" hinzu ohne den Wert. Der Wert steht dafür in irgendeinem Kommentar. Dafür hat Algora eine eigene API, die man anzapfen könnte...

Hier ist der Code soweit:

Code: Alles auswählen

from github import Github
from github import Auth
from os import environ

COOL_REPO = "Mudlet/Mudlet"
COOL_EVENTS = ["labeled"]
COOL_LABELS = ["bounty-paid", "bounty-20", "bounty-30", "bounty-50", "bounty-80", "bounty-100", "bounty-120", "bounty-200" ]

try:
    GITHUB_ACCESS_TOKEN = environ["GITHUB_ACCESS_TOKEN"]
except KeyError:
    raise(ValueError("Token not available!"))


class Bounty():
  def __init__(self, number, reward = 0, title = ""):
    self.number = number
    self.reward = reward
    if title == "":
      self.title = "Title unknown"
    else:
      self.title = title
      
    self.status = "plan"
    self.url = f"https://github.com/Mudlet/Mudlet/issues/{self.number}"
    self.start_date = ""
    self.end_date = ""

  def publish(self, start_date, reward):
    self.start_date = start_date
    self.reward = reward
    self.status = "open"

  def close(self, end_date, status = "abort"):
    self.end_date = end_date
    self.status = status

  def pay(self, end_date):
    self.end_date = end_date
    self.status = "paid"

  def __str__(self):
    link_string = f'=HYPERLINK("{self.url}";"{self.title}")'
    return "\t".join((str(self.number), 
      link_string, 
      self.status, 
      str(self.reward), 
      str(self.start_date), 
      str(self.end_date)))

  def __repr__(self):
    return self.__str__()

  def __eq__(self, other): 
    result = (self.number == other.number) and \
      (self.start_date == other.start_date)
    return result

  def __ne__(self, other): 
    result = (self.number != other.number) or \
      (self.start_date != other.start_date)
    return result
    
  def __lt__(self, other): 
    result = (self.number < other.number) and \
      (self.start_date < other.start_date)
    return result

  def __le__(self, other): 
    result = (self.number <= other.number) and \
      (self.start_date <= other.start_date)
    return result

  def __gt__(self, other): 
    result = (self.number > other.number) and \
      (self.start_date > other.start_date)
    return result

  def __ge__(self, other): 
    result = (self.number >= other.number) and \
      (self.start_date >= other.start_date)
    return result

  def __hash__(self):
    return hash((self.number, self.start_date))


def is_no_pull_request(issue):
    return issue.pull_request is None


def gather_cool_issues(repo):
    cool_issues = set()
    for label in COOL_LABELS:
        cool_issues.update(repo.get_issues(
            labels = [label], state = "all"))

    cool_issues = set(c for c in cool_issues if is_no_pull_request(c))
    return cool_issues


def parse_issue(issue):
    bounty = Bounty(issue.number, title = issue.title)
    bounties = {bounty}
    for event in [e for e in issue.get_timeline() if e.event in COOL_EVENTS]:
        label_name = event.raw_data['label']['name']
        if label_name not in COOL_LABELS:
            continue

        if label_name == "bounty-paid":
            bounty.pay(end_date = event.created_at)
            continue

        # Now the label must either be "bounty-20", or "bounty-30", etc.
        reward = label_name.split("-")[1]
        if bounty.status == "open":
            # This bounty was already published before, now changed to a different reward.
            # Then we will close the original bounty, and create a new bounty with the new reward.
            bounty.close(end_date = event.created_at, status = "rise")
            bounty = Bounty(issue.number, title = issue.title)
            bounties.add(bounty)

        bounty.publish(start_date = event.created_at, reward = reward)

    return bounties


def main():
    auth = Auth.Token(GITHUB_ACCESS_TOKEN)
    github = Github(auth=auth)
    repo = github.get_repo(COOL_REPO)

    cool_issues = gather_cool_issues(repo)
    bounties = set()
    for issue in cool_issues:
        bounties.update(parse_issue(issue))

    for line in sorted(bounties):
        print(line)
    

if __name__ == "__main__":
    main()

Danke für die Aufmerksamkeit!
MorgenGrauen: 1 Welt, 8 Rassen, 13 Gilden, >250 Abenteuer, >5000 Waffen & Rüstungen,
>7000 NPC, >16000 Räume, >200 freiwillige Programmierer, nur Text, viel Spaß, seit 1992.
Benutzeravatar
__blackjack__
User
Beiträge: 13117
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

Wenn `str()` und `repr()` gleich sein sollen braucht man nur `__repr__()` implementieren, denn `object.__str__()` benutzt `__repr__()`. Allerdings entspricht das da nicht den Konventionen von `__repr__()`: Entweder etwas das man als Python-Ausdruck auswerten könnte um den gleichen Wert zu bekommen, oder etwas in ”spitze Klammern” eingefasst, was sich zur Fehlersuche eignet.

Von den ganzen Vergleichsfunktionen muss man nicht alle selbst implementieren wenn man die Klasse mit `functools.total_ordering` dekoriert.
„All religions are the same: religion is basically guilt, with different holidays.” — Cathy Ladman
Benutzeravatar
Kebap
User
Beiträge: 687
Registriert: Dienstag 15. November 2011, 14:20
Wohnort: Dortmund

Guter Hinweis. Den Unterschied zwischen __str__ und __repr__ hatte ich noch nicht so verinnerlicht.
Dieses total_ordering ist neumodischer Python 3 Schnickschnack. Sogar das offizielle Python Tutorial empfiehlt es bei Gelegenheit wegzulassen, weil es Dinge verlangsamen und kompliziertere Stacktraces ergeben könnte.
Naja, immerhin spart es etwas Tipparbeit und größtenteils identischen Code zu wiederholen. Habe ich mal testweise eingebaut und einige Vergleichsfunktionen gestrichen. Dann landen wir hier (getestet):

Code: Alles auswählen

from functools import total_ordering
from github import Github
from github import Auth
from os import environ

COOL_REPO = "Mudlet/Mudlet"
COOL_EVENTS = ["labeled"]
COOL_LABELS = ["bounty-paid", "bounty-20", "bounty-30", "bounty-50", "bounty-80", "bounty-100", "bounty-120", "bounty-200" ]

try:
    GITHUB_ACCESS_TOKEN = environ["GITHUB_ACCESS_TOKEN"]
except KeyError:
    raise(ValueError("Token not available!"))


@total_ordering
class Bounty():
  def __init__(self, number, reward = 0, title = ""):
    self.number = number
    self.reward = reward
    if title == "":
      self.title = "Title unknown"
    else:
      self.title = title
      
    self.status = "plan"
    self.url = f"https://github.com/Mudlet/Mudlet/issues/{self.number}"
    self.start_date = ""
    self.end_date = ""

  def publish(self, start_date, reward):
    self.start_date = start_date
    self.reward = reward
    self.status = "open"

  def close(self, end_date, status = "abort"):
    self.end_date = end_date
    self.status = status

  def pay(self, end_date):
    self.end_date = end_date
    self.status = "paid"

  def __str__(self):
    link_string = f'=HYPERLINK("{self.url}";"{self.title}")'
    return "\t".join((
      str(self.number), 
      link_string, 
      self.status, 
      str(self.reward), 
      str(self.start_date), 
      str(self.end_date)))

  def __repr__(self):
    return "Bounty(%r, %r, %r)" % (self.number, self.reward, self.title)

  def __eq__(self, other): 
    result = (self.number == other.number) and \
      (self.start_date == other.start_date)
    return result
    
  def __lt__(self, other): 
    result = (self.number < other.number) and \
      (self.start_date < other.start_date)
    return result

  def __hash__(self):
    return hash((self.number, self.start_date))


def is_no_pull_request(issue):
    return issue.pull_request is None


def gather_cool_issues(repo):
    cool_issues = set()
    for label in COOL_LABELS:
        cool_issues.update(repo.get_issues(
            labels = [label], state = "all"))

    cool_issues = set(c for c in cool_issues if is_no_pull_request(c))
    return cool_issues


def parse_issue(issue):
    bounty = Bounty(issue.number, title = issue.title)
    bounties = {bounty}
    for event in [e for e in issue.get_timeline() if e.event in COOL_EVENTS]:
        label_name = event.raw_data['label']['name']
        if label_name not in COOL_LABELS:
            continue

        if label_name == "bounty-paid":
            bounty.pay(end_date = event.created_at)
            continue

        # Now the label must either be "bounty-20", or "bounty-30", etc.
        reward = label_name.split("-")[1]
        if bounty.status == "open":
            # This bounty was already published before, now changed to a different reward.
            # Then we will close the original bounty, and create a new bounty with the new reward.
            bounty.close(end_date = event.created_at, status = "rise")
            bounty = Bounty(issue.number, title = issue.title)
            bounties.add(bounty)

        bounty.publish(start_date = event.created_at, reward = reward)

    return bounties


def main():
    auth = Auth.Token(GITHUB_ACCESS_TOKEN)
    github = Github(auth=auth)
    repo = github.get_repo(COOL_REPO)

    cool_issues = gather_cool_issues(repo)
    bounties = set()
    for issue in cool_issues:
        bounties.update(parse_issue(issue))

    for line in sorted(bounties):
        print(line)
    

if __name__ == "__main__":
    main()
Was mich selbst noch bisschen störte, ist diese Übergabe von mehreren Bounties als Set. Ich würde gerne immer genau ein Bounty pro Issue zurückbekommen. Dann könnte ich auch die "parse_issue" Funktion weiter auftrennen und vielleicht die Schleife in eine andere Funktion auslagern. Aber ich habe es nicht geschafft. Ich würde vermutlich der Bounty Klasse gerne beibringen, was sie zu tun hat, wenn ihr Reward erhöht wird, aber es geht nicht, weil ja dann ein Bounty geschlossen und ein anderes neu eröffnet wird. Das geht also über das einzelne Exemplar hinaus. Alternativ müsste ich plötzlich innerhalb einer Bounty Klasse mehrere Termine merken, ab wann welcher Reward gesetzt wird, damit zumindest die __str__ Ausgabe dann wieder mehrzeilig erfolgen kann. Da fehlte mir noch ein schönes Designmuster.
MorgenGrauen: 1 Welt, 8 Rassen, 13 Gilden, >250 Abenteuer, >5000 Waffen & Rüstungen,
>7000 NPC, >16000 Räume, >200 freiwillige Programmierer, nur Text, viel Spaß, seit 1992.
Benutzeravatar
grubenfox
User
Beiträge: 432
Registriert: Freitag 2. Dezember 2022, 15:49

Kebap hat geschrieben: Dienstag 16. Januar 2024, 22:00 Dieses total_ordering ist neumodischer Python 3 Schnickschnack.
:D Wortwörtlich meine Gedanken wenn ich mal wieder irgendwelche Neuigkeiten in Python 3 entdecke. 'total_ordering' ist natürlich durch die jeweils aktuelle Entdeckung zu ersetzen und nicht alles neue in Python ist nur Schnickschnack. Es gibt z.B. auch Dinge bei denen ich mir denke: Warum erst jetzt, warum hatten wir das nicht schon viel früher in Python?
Kebap hat geschrieben: Dienstag 16. Januar 2024, 22:00 Ich würde vermutlich der Bounty Klasse gerne beibringen, was sie zu tun hat, wenn ihr Reward erhöht wird, aber es geht nicht, weil ja dann ein Bounty geschlossen und ein anderes neu eröffnet wird. Das geht also über das einzelne Exemplar hinaus. Alternativ müsste ich plötzlich innerhalb einer Bounty Klasse mehrere Termine merken, ab wann welcher Reward gesetzt wird, damit zumindest die __str__ Ausgabe dann wieder mehrzeilig erfolgen kann. Da fehlte mir noch ein schönes Designmuster.
Wenn es über einzelne Exemplare hinaus geht, dann denke ich gleich an einen Manager... eine Klasse die intern das eine Set mit den Bounty-Instanzen verwaltet. Ich glaube "Composite" müsste das Stichwort sein...
Benutzeravatar
Kebap
User
Beiträge: 687
Registriert: Dienstag 15. November 2011, 14:20
Wohnort: Dortmund

Ja, das war tatsächlich eher als Scherz formuliert.
MorgenGrauen: 1 Welt, 8 Rassen, 13 Gilden, >250 Abenteuer, >5000 Waffen & Rüstungen,
>7000 NPC, >16000 Räume, >200 freiwillige Programmierer, nur Text, viel Spaß, seit 1992.
Benutzeravatar
Kebap
User
Beiträge: 687
Registriert: Dienstag 15. November 2011, 14:20
Wohnort: Dortmund

OK, einen Monat später möchte ich die Auswertung erneut durchführen und mit den letzten Ergebnissen abgleichen.
Die hätte ich dazu vermutlich in einem lesbaren Format speichern sollen und nicht bloß per Copy/Paste manuell weiterverarbeiten.
Was empfiehlt sich da so?
MorgenGrauen: 1 Welt, 8 Rassen, 13 Gilden, >250 Abenteuer, >5000 Waffen & Rüstungen,
>7000 NPC, >16000 Räume, >200 freiwillige Programmierer, nur Text, viel Spaß, seit 1992.
Benutzeravatar
Kebap
User
Beiträge: 687
Registriert: Dienstag 15. November 2011, 14:20
Wohnort: Dortmund

Ich frage mal anders, weil ich dem Problem so ähnlich schon in manchen Projekten begegnet bin.
Irgendwie fehlt mir da noch ein richtiger Ansatz oder eine übliche Vorgehensweise oder ich weiß nicht.
Vermutlich könnten Datenbanken dabei helfen? Aber das ist eigentlich nicht der Kern meiner Frage.

Ich will heute den Stand von X Daten herunterladen, und morgen auch nochmal diese Daten herunterladen.
Aber vielleicht sind es inzwischen mehr Daten geworden, oder die alten haben sich verändert, usw.
Womöglich haben sich auch die lokalen Daten zwischenzeitlich verändert, aber die entfernten nicht.
Dann muss man abgleichen, was neu ist, und beibehalten werden soll. Vermutlich gibt es noch mehr zu beachten.
Das beste Vorgehen an der Stelle ist mir aber noch nicht klar. Hoffentlich erfinde ich nicht das Rad nochmal neu.
MorgenGrauen: 1 Welt, 8 Rassen, 13 Gilden, >250 Abenteuer, >5000 Waffen & Rüstungen,
>7000 NPC, >16000 Räume, >200 freiwillige Programmierer, nur Text, viel Spaß, seit 1992.
Antworten