Compare commits

...

10 Commits

Author SHA1 Message Date
75fcbb4452 Preparation for new year of PIC 2025-11-18 15:18:10 +00:00
Enzo Nunes
7c38a708b4 fix invalid urls 2025-03-01 19:20:17 +00:00
Enzo Nunes
2e6a6f23c8 finish merge 2025-02-28 19:23:07 +00:00
Enzo Nunes
d243ba1474 add storage safeguard. add locks to prevent race conditions. 2025-02-28 19:22:02 +00:00
Enzo Nunes
46db758bd2 add mention on message after timeout 2025-02-27 11:19:26 +00:00
Enzo Nunes
6bfed664d6 extend timeout to 10min 2025-02-27 11:02:19 +00:00
Enzo Nunes
e43824165c (attemp to) fix: thread no longer blocking 2025-02-26 22:48:37 +00:00
Enzo Nunes
517d3085bb another slight change to almost picable string 2025-02-26 10:44:19 +00:00
Enzo Nunes
98ce4635ab slight change to almost picable string 2025-02-26 10:42:18 +00:00
Enzo Nunes
e5ff919685 major logic changes. improve performance. simplify code. 2025-02-26 02:10:09 +00:00
7 changed files with 146 additions and 59 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
**/__pycache__/ **/__pycache__/
**/*_tok **/*_tok
**/.idea/

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.14-slim
# Copy your code
WORKDIR /app
RUN pip install --no-cache-dir discord asyncio requests
COPY main.py .
COPY PICable.py .
# Drop privileges (optional but good practice)
RUN useradd -m appuser
USER appuser
# Start the scheduler script
CMD ["python", "main.py"]

View File

@@ -10,6 +10,7 @@ LIMIT_STARS = 200
LIMIT_NLOC = 100000 LIMIT_NLOC = 100000
MARGIN = 0.1 MARGIN = 0.1
CLONE_DIR = "~/temp/PICable" CLONE_DIR = "~/temp/PICable"
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,37 @@ 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:
subprocess.run( subprocess.run(
["git", "clone", repo_url, repository_clone_dir], ["git", "clone", "--depth", "1", repository_url, repository_clone_dir],
check=True, check=True,
stdout=sys.stdout, stdout=sys.stdout,
stderr=sys.stderr, stderr=sys.stderr,
) )
result = subprocess.run(
["cloc", f"--exclude-lang={','.join(EXCLUDED_LANGUAGES)}", repository_clone_dir],
capture_output=True,
text=True,
check=True,
)
number_of_lines_of_code = int(result.stdout.split("\n")[-3].split()[-1])
except Exception: except Exception:
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 +125,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

View File

@@ -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
View File

@@ -0,0 +1,8 @@
services:
picable:
build: .
container_name: picable
environment:
- PICABLE_DISCORD_TOKEN=yourdiscordtoken
- PICABLE_GITHUB_TOKEN=yourgithubtoken
restart: unless-stopped

127
main.py
View File

@@ -1,36 +1,38 @@
import asyncio from asyncio import Condition, Lock, create_task, shield, to_thread, wait_for
from sys import argv from sys import argv
from os import getenv
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 = 10 * 1024 * 1024 # 10GB
print("Usage:\n\t" + argv[0] + " <discord_token> <github_token>")
if __name__ == "__main__":
current_storage_kb = 0
storage_condition = Condition()
current_repositories = set()
current_repositories_lock = Lock()
discord_token = getenv("PICABLE_DISCORD_TOKEN")
github_token = getenv("PICABLE_GITHUB_TOKEN")
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)
client.run(discord_token)
async def reply_message_async(interaction: discord.Interaction, owner: str, repo: str): @client.event
try: async def on_ready():
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
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 +40,77 @@ 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(
f"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()

1
run.sh
View File

@@ -1 +0,0 @@
python3 main.py MTM0MzMzNzAzMzkyOTU4ODg5MA.Gx2pEq.uLdXFORUFlYurLUF6Lnsuh_EWFQRI8SAKFebDM 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