Compare commits
16 Commits
2731dbf995
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a4fb621ad | ||
|
|
1cac1d2e66 | ||
| 06d3098940 | |||
|
|
cbcd90732d | ||
| 6c7eafb8d3 | |||
|
|
cd60dd5653 | ||
| 75fcbb4452 | |||
|
|
7c38a708b4 | ||
|
|
2e6a6f23c8 | ||
|
|
d243ba1474 | ||
|
|
46db758bd2 | ||
|
|
6bfed664d6 | ||
|
|
e43824165c | ||
|
|
517d3085bb | ||
|
|
98ce4635ab | ||
|
|
e5ff919685 |
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Copy this file to .env and fill in your actual tokens
|
||||||
|
# The .env file is gitignored and will be used for local development
|
||||||
|
|
||||||
|
PICABLE_DISCORD_TOKEN=your_discord_token_here
|
||||||
|
PICABLE_GITHUB_TOKEN=your_github_token_here
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,2 +1,7 @@
|
|||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
**/*_tok
|
**/*_tok
|
||||||
|
**/.idea/
|
||||||
|
|
||||||
|
# Environment variables and local development overrides
|
||||||
|
.env
|
||||||
|
docker-compose.override.yml
|
||||||
|
|||||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
FROM python:3.14-slim
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
apt-get update; \
|
||||||
|
apt-get install -y --no-install-recommends cloc git; \
|
||||||
|
apt-get clean; \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
RUN pip install --no-cache-dir discord.py requests
|
||||||
|
|
||||||
|
COPY main.py .
|
||||||
|
COPY PICable.py .
|
||||||
|
|
||||||
|
RUN useradd -m appuser && \
|
||||||
|
mkdir -p /temp && \
|
||||||
|
chown appuser:appuser /temp
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
CMD ["python", "-u", "main.py"]
|
||||||
66
PICable.py
66
PICable.py
@@ -1,15 +1,16 @@
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
LIMIT_COMMITS_30_DAYS = 50
|
LIMIT_COMMITS_30_DAYS = int(os.getenv("LIMIT_COMMITS_30_DAYS", "50"))
|
||||||
LIMIT_STARS = 200
|
LIMIT_STARS = int(os.getenv("LIMIT_STARS", "200"))
|
||||||
LIMIT_NLOC = 100000
|
LIMIT_NLOC = int(os.getenv("LIMIT_NLOC", "100000"))
|
||||||
|
|
||||||
MARGIN = 0.1
|
MARGIN = 0.1
|
||||||
CLONE_DIR = "~/temp/PICable"
|
CLONE_DIR = "/temp"
|
||||||
|
EXCLUDED_LANGUAGES = ["CSV", "diff", "INI", "JSON", "Markdown", "reStructuredText", "SVG", "Text", "YAML"]
|
||||||
|
|
||||||
|
|
||||||
def get_stars_info(owner, repository, request_headers):
|
def get_stars_info(owner, repository, request_headers):
|
||||||
@@ -47,45 +48,45 @@ def get_commits_last_30_days(owner, repository, request_headers):
|
|||||||
|
|
||||||
|
|
||||||
def get_lines_of_code(owner, repository):
|
def get_lines_of_code(owner, repository):
|
||||||
repo_url = f"https://github.com/{owner}/{repository}.git"
|
repository_url = f"https://github.com/{owner}/{repository}.git"
|
||||||
repository_clone_dir = os.path.expanduser(f"{CLONE_DIR}/{repository}")
|
parent_dir = os.path.expanduser(f"{CLONE_DIR}/{owner}")
|
||||||
|
repository_clone_dir = os.path.expanduser(f"{parent_dir}/{repository}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
print(f"Cloning {repository_url}...")
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["git", "clone", repo_url, repository_clone_dir],
|
["git", "clone", "--depth", "1", "--progress", repository_url, repository_clone_dir],
|
||||||
check=True,
|
check=True,
|
||||||
stdout=sys.stdout,
|
|
||||||
stderr=sys.stderr,
|
|
||||||
)
|
)
|
||||||
except Exception:
|
print("Clone complete. Running cloc...")
|
||||||
|
result = subprocess.run(
|
||||||
|
["cloc", f"--exclude-lang={','.join(EXCLUDED_LANGUAGES)}", repository_clone_dir],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find the SUM line and extract the last column (code lines)
|
||||||
|
for line in result.stdout.split("\n"):
|
||||||
|
if line.strip().startswith("SUM:"):
|
||||||
|
number_of_lines_of_code = int(line.split()[-1])
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise ValueError("Could not find SUM line in cloc output")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error occurred: {e}")
|
||||||
return -1
|
return -1
|
||||||
|
finally:
|
||||||
result = subprocess.run(["sloccount", repository_clone_dir], capture_output=True, text=True, check=True)
|
subprocess.run(["rm", "-rf", parent_dir], check=True)
|
||||||
for line in result.stdout.splitlines():
|
return number_of_lines_of_code
|
||||||
if "Total Physical Source Lines of Code (SLOC)" in line:
|
|
||||||
loc = int(line.split()[8].replace(",", ""))
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
loc = 0
|
|
||||||
subprocess.run(["rm", "-rf", repository_clone_dir], check=True)
|
|
||||||
return loc
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_repository(owner, repository, request_headers):
|
|
||||||
repository_url = f"https://api.github.com/repos/{owner}/{repository}"
|
|
||||||
repository_response = requests.get(repository_url, headers=request_headers)
|
|
||||||
return repository_response.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
def PICable(owner, repository, token):
|
def PICable(owner, repository, token):
|
||||||
margin_percentage = round(MARGIN * 100)
|
margin_percentage = round(MARGIN * 100)
|
||||||
request_headers = {"Authorization": f"token {token}"}
|
request_headers = {"Authorization": f"token {token}"}
|
||||||
picable_report = ""
|
picable_report = f"Analysis complete for the repository {owner}/{repository}.\n\n"
|
||||||
repository_url = f"https://www.github.com/{owner}/{repository}"
|
repository_url = f"https://www.github.com/{owner}/{repository}"
|
||||||
|
|
||||||
if not is_valid_repository(owner, repository, request_headers):
|
|
||||||
return f"Invalid repository: {owner}/{repository} :x:"
|
|
||||||
|
|
||||||
stars = get_stars_info(owner, repository, request_headers)
|
stars = get_stars_info(owner, repository, request_headers)
|
||||||
if stars == -1:
|
if stars == -1:
|
||||||
return "Error fetching stars info. :x:"
|
return "Error fetching stars info. :x:"
|
||||||
@@ -132,6 +133,7 @@ def PICable(owner, repository, token):
|
|||||||
elif stars >= LIMIT_STARS and commits_30_days >= LIMIT_COMMITS_30_DAYS and lines_of_code >= LIMIT_NLOC:
|
elif stars >= LIMIT_STARS and commits_30_days >= LIMIT_COMMITS_30_DAYS and lines_of_code >= LIMIT_NLOC:
|
||||||
picable_report += f"The repository {repository_url} is PICable. :white_check_mark:\n"
|
picable_report += f"The repository {repository_url} is PICable. :white_check_mark:\n"
|
||||||
else:
|
else:
|
||||||
picable_report += f"The repository {repository_url} is almost PICable. :warning:\nThere is at least one requirement which is below the requirement, but by less than {margin_percentage}%. Consult with a professor before proceeding.\n"
|
picable_report += f"The repository {repository_url} is almost PICable. :warning:\nThere is at least one parameter which is below the requirement, but by less than {margin_percentage}%.\nConsult with a professor before proceeding.\n"
|
||||||
|
|
||||||
|
print("Analysis complete.")
|
||||||
return picable_report
|
return picable_report
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
# PICable
|
# PICable
|
||||||
|
|
||||||
Simple script to determine if a project is eligible as the final PIC of LEIC-T.
|
Simple script to determine if a project is eligible as the final PIC of LEIC-T.
|
||||||
|
|||||||
8
docker-compose.yml
Normal file
8
docker-compose.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
services:
|
||||||
|
picable:
|
||||||
|
image: didas72/picable:latest
|
||||||
|
container_name: picable
|
||||||
|
environment:
|
||||||
|
- PICABLE_DISCORD_TOKEN=${PICABLE_DISCORD_TOKEN}
|
||||||
|
- PICABLE_GITHUB_TOKEN=${PICABLE_GITHUB_TOKEN}
|
||||||
|
restart: unless-stopped
|
||||||
121
main.py
121
main.py
@@ -1,36 +1,34 @@
|
|||||||
import asyncio
|
import os
|
||||||
from sys import argv
|
from asyncio import Condition, Lock, create_task, shield, to_thread, wait_for
|
||||||
|
|
||||||
import discord
|
import requests
|
||||||
|
from discord import Game, Intents, Interaction, app_commands
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
import PICable
|
from PICable import PICable
|
||||||
|
|
||||||
if len(argv) != 3:
|
MAX_STORAGE_KB = 50 * 1024 * 1024 # 50GB
|
||||||
print("Usage:\n\t" + argv[0] + " <discord_token> <github_token>")
|
DISCORD_TOKEN = os.getenv("PICABLE_DISCORD_TOKEN")
|
||||||
|
GITHUB_TOKEN = os.getenv("PICABLE_GITHUB_TOKEN")
|
||||||
|
|
||||||
|
current_storage_kb = 0
|
||||||
|
storage_condition = Condition()
|
||||||
|
current_repositories = set()
|
||||||
|
current_repositories_lock = Lock()
|
||||||
|
|
||||||
|
if not DISCORD_TOKEN or not GITHUB_TOKEN:
|
||||||
|
print("Discord or Github tokens not set in env. Use variables PICABLE_DISCORD_TOKEN and PICABLE_GITHUB_TOKEN")
|
||||||
exit(1)
|
exit(1)
|
||||||
discord_token = argv[1]
|
|
||||||
github_token = argv[2]
|
|
||||||
|
|
||||||
intents = discord.Intents.default()
|
intents = Intents.default()
|
||||||
intents.message_content = True
|
intents.message_content = True
|
||||||
client = commands.Bot(command_prefix="/", intents=intents)
|
client = commands.Bot(command_prefix="/", intents=intents)
|
||||||
|
|
||||||
|
|
||||||
async def reply_message_async(interaction: discord.Interaction, owner: str, repo: str):
|
|
||||||
try:
|
|
||||||
result = await asyncio.to_thread(PICable.PICable, owner, repo, github_token)
|
|
||||||
await interaction.followup.send(result)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error processing PICable check: {e}")
|
|
||||||
await interaction.followup.send("An error occurred while processing your request. Please try again later.")
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
@client.event
|
@client.event
|
||||||
async def on_ready():
|
async def on_ready():
|
||||||
print(f"We have logged in as {client.user}")
|
print(f"We have logged in as {client.user}")
|
||||||
await client.change_presence(activity=discord.Game("with PIC ideas"))
|
await client.change_presence(activity=Game("with PIC ideas"))
|
||||||
try:
|
try:
|
||||||
synced = await client.tree.sync()
|
synced = await client.tree.sync()
|
||||||
print(f"Synced {len(synced)} command(s)")
|
print(f"Synced {len(synced)} command(s)")
|
||||||
@@ -38,10 +36,81 @@ async def on_ready():
|
|||||||
print(f"Failed to sync commands: {e}")
|
print(f"Failed to sync commands: {e}")
|
||||||
|
|
||||||
|
|
||||||
@client.tree.command(name="picable", description="Check if a Github repository is eligible for PIC.")
|
@client.tree.command(name="ping", description="Check the bot's latency.")
|
||||||
@discord.app_commands.describe(owner="The owner of the repository", repository="The name of the repository")
|
async def ping(interaction: Interaction):
|
||||||
async def picable(interaction: discord.Interaction, owner: str, repository: str):
|
latency = client.latency * 1000
|
||||||
await interaction.response.defer(ephemeral=False, thinking=True)
|
await interaction.response.send_message(f"Pong! Latency: {latency:.2f}ms")
|
||||||
asyncio.create_task(reply_message_async(interaction, owner, repository))
|
|
||||||
|
|
||||||
client.run(discord_token)
|
|
||||||
|
async def handle_picable(owner: str, repository: str, repository_size_kb: int, github_token: str):
|
||||||
|
global current_storage_kb
|
||||||
|
async with storage_condition:
|
||||||
|
await storage_condition.wait_for(lambda: current_storage_kb + repository_size_kb <= MAX_STORAGE_KB)
|
||||||
|
current_storage_kb += repository_size_kb
|
||||||
|
|
||||||
|
print(f"Storage is {current_storage_kb / MAX_STORAGE_KB * 100:.2f}% full.")
|
||||||
|
return await to_thread(PICable, owner, repository, github_token)
|
||||||
|
|
||||||
|
|
||||||
|
@client.tree.command(name="picable", description="Check if a Github repository is eligible for PIC.")
|
||||||
|
@app_commands.describe(owner="The owner of the repository", repository="The name of the repository")
|
||||||
|
async def picable(interaction: Interaction, owner: str, repository: str):
|
||||||
|
await interaction.response.defer(thinking=True)
|
||||||
|
|
||||||
|
repository_full_name = f"{owner}/{repository}"
|
||||||
|
async with current_repositories_lock:
|
||||||
|
if repository_full_name in current_repositories:
|
||||||
|
await interaction.followup.send(
|
||||||
|
f"The repository {repository_full_name} is already being analyzed. Please wait for the current analysis to complete. :warning:"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
current_repositories.add(repository_full_name)
|
||||||
|
|
||||||
|
url = f"https://api.github.com/repos/{owner}/{repository}"
|
||||||
|
headers = {"Authorization": f"token {GITHUB_TOKEN}"}
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
|
||||||
|
if not response.status_code == 200:
|
||||||
|
await interaction.followup.send(
|
||||||
|
"Invalid Repository. Please check the repository name and owner and try again. :x:"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
repo_info = response.json()
|
||||||
|
repository_size_kb = repo_info["size"]
|
||||||
|
print(f"Repository size: {repository_size_kb / 1024} MB")
|
||||||
|
|
||||||
|
if repository_size_kb > MAX_STORAGE_KB:
|
||||||
|
await interaction.followup.send(
|
||||||
|
f"The repository {repository_full_name} is too large to analyze. Please try a smaller repository. :warning:"
|
||||||
|
)
|
||||||
|
current_repositories.remove(repository_full_name)
|
||||||
|
return
|
||||||
|
|
||||||
|
global current_storage_kb
|
||||||
|
try:
|
||||||
|
result_future = create_task(handle_picable(owner, repository, repository_size_kb, GITHUB_TOKEN))
|
||||||
|
try:
|
||||||
|
result = await wait_for(shield(result_future), timeout=600)
|
||||||
|
await interaction.followup.send(result)
|
||||||
|
except TimeoutError:
|
||||||
|
await interaction.followup.send(
|
||||||
|
f"The analysis for {repository_full_name} is still taking place. The results will be posted here once the analysis is complete. :clock4:"
|
||||||
|
)
|
||||||
|
result = await result_future
|
||||||
|
await interaction.channel.send(f"{interaction.user.mention}\n{result}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error processing PICable check: {e}")
|
||||||
|
await interaction.channel.send("An error occurred while processing your request. Please try again later.")
|
||||||
|
finally:
|
||||||
|
async with current_repositories_lock:
|
||||||
|
current_repositories.remove(repository_full_name)
|
||||||
|
async with storage_condition:
|
||||||
|
current_storage_kb -= repository_size_kb
|
||||||
|
storage_condition.notify_all()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
client.run(DISCORD_TOKEN)
|
||||||
|
|||||||
Reference in New Issue
Block a user