Compare commits

...

27 Commits

Author SHA1 Message Date
3a511660bb
feat: remove all .lock files
All checks were successful
build / trigger-build-image (push) Successful in 14s
2025-10-09 00:14:34 -07:00
9fc7649696
feat: remove more locks
All checks were successful
build / trigger-build-image (push) Successful in 12s
2025-10-08 20:29:43 -07:00
e160023cbf
chore: log more commits length
All checks were successful
build / trigger-build-image (push) Successful in 16s
2025-10-08 20:21:27 -07:00
99d889ee12
feat: check stderr isatty for colorize
All checks were successful
build / trigger-build-image (push) Successful in 15s
2025-10-03 00:00:44 -07:00
f755fb44f6
chore: log unwatch
All checks were successful
build / trigger-build-image (push) Successful in 16s
2025-09-28 18:07:11 -07:00
94d3f993b2
feat: remove output repos in joj3-check-gitea-token
All checks were successful
build / trigger-build-image (push) Successful in 15s
2025-09-21 06:15:10 -07:00
aa33dcc2f1
feat: list orgs in joj3-check-gitea-token
All checks were successful
build / trigger-build-image (push) Successful in 8s
2025-09-21 06:02:58 -07:00
e24545324d
revert: "fix: disable file log in joj3-check-gitea-token"
This reverts commit 3dc6667716.
2025-09-21 06:01:04 -07:00
3dc6667716
fix: disable file log in joj3-check-gitea-token
All checks were successful
build / trigger-build-image (push) Successful in 8s
2025-09-21 05:26:08 -07:00
5b6c61af6d
fix: echo user
All checks were successful
build / trigger-build-image (push) Successful in 10s
2025-09-21 05:03:13 -07:00
8264152022
feat: joj3-check-gitea-token
All checks were successful
build / trigger-build-image (push) Successful in 9s
2025-09-21 05:01:03 -07:00
992f450004
feat: check current gitea user for joj3
All checks were successful
build / trigger-build-image (push) Successful in 24s
2025-09-21 04:56:14 -07:00
5478052c23
chore: updgrade to latest hooks 2025-09-21 04:53:13 -07:00
b64ef1198e
chore: subject: wrong Canvas email
All checks were successful
build / trigger-build-image (push) Successful in 15s
2025-09-08 22:44:04 -07:00
f8ff9fd0f9
feat: sample to, subject, body
All checks were successful
build / trigger-build-image (push) Successful in 11s
2025-09-08 22:40:41 -07:00
d28fdaf492
feat: export email in export-users
All checks were successful
build / trigger-build-image (push) Successful in 30s
2025-09-08 22:33:03 -07:00
14154fb59b
fix: typo
All checks were successful
build / trigger-build-image (push) Successful in 21s
2025-08-03 14:40:41 -07:00
cb5735ac40
feat: remove checkout dest lock
All checks were successful
build / trigger-build-image (push) Successful in 10s
2025-08-03 14:40:22 -07:00
083140079e
feat: remove current local head lock
All checks were successful
build / trigger-build-image (push) Successful in 18s
2025-08-03 14:39:09 -07:00
aa9a69eaf1
feat: support git ref as scoreboard column
All checks were successful
build / trigger-build-image (push) Successful in 10s
2025-07-27 03:26:24 -07:00
2d7aba5ce0
fix: typo
All checks were successful
build / trigger-build-image (push) Successful in 11s
2025-07-27 03:09:10 -07:00
0b45898b91
feat: repo name in scoreboard
All checks were successful
build / trigger-build-image (push) Successful in 13s
2025-07-27 02:42:00 -07:00
353797323d
feat(canvas): export all users
All checks were successful
build / trigger-build-image (push) Successful in 13s
2025-07-03 08:46:05 -04:00
beeb45709f
fix: penalty factor calculation
All checks were successful
build / trigger-build-image (push) Successful in 13s
2025-06-30 22:49:26 -04:00
54a4f404fe
fix: penalty for negative score
All checks were successful
build / trigger-build-image (push) Successful in 13s
2025-06-30 08:09:07 -04:00
9e31fc71be
feat: support --issue-label-exclusive
All checks were successful
build / trigger-build-image (push) Successful in 12s
2025-06-25 04:59:23 -04:00
c3b053f0a5
fix: total score penalty warning
All checks were successful
build / trigger-build-image (push) Successful in 11s
2025-06-24 09:31:39 -04:00
8 changed files with 125 additions and 62 deletions

View File

@ -1,41 +1,41 @@
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0 rev: v6.0.0
hooks: hooks:
- id: check-yaml - id: check-yaml
- id: end-of-file-fixer - id: end-of-file-fixer
- id: trailing-whitespace - id: trailing-whitespace
- id: requirements-txt-fixer - id: requirements-txt-fixer
- repo: https://github.com/pre-commit/mirrors-mypy - repo: https://github.com/pre-commit/mirrors-mypy
rev: "v1.4.1" rev: "v1.18.2"
hooks: hooks:
- id: mypy - id: mypy
additional_dependencies: additional_dependencies:
- pydantic - pydantic
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/asottile/pyupgrade
rev: v3.9.0 rev: v3.20.0
hooks: hooks:
- id: pyupgrade - id: pyupgrade
- repo: https://github.com/hadialqattan/pycln - repo: https://github.com/hadialqattan/pycln
rev: v2.4.0 rev: v2.5.0
hooks: hooks:
- id: pycln - id: pycln
args: [-a] args: [-a]
- repo: https://github.com/PyCQA/bandit - repo: https://github.com/PyCQA/bandit
rev: '1.7.5' rev: '1.8.6'
hooks: hooks:
- id: bandit - id: bandit
- repo: https://github.com/PyCQA/isort - repo: https://github.com/PyCQA/isort
rev: 5.12.0 rev: 6.0.1
hooks: hooks:
- id: isort - id: isort
args: ["--profile", "black", "--filter-files"] args: ["--profile", "black", "--filter-files"]
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 23.7.0 rev: 25.9.0
hooks: hooks:
- id: black - id: black
- repo: https://github.com/Lucas-C/pre-commit-hooks - repo: https://github.com/Lucas-C/pre-commit-hooks
rev: v1.5.1 rev: v1.5.5
hooks: hooks:
- id: remove-crlf - id: remove-crlf
- id: remove-tabs - id: remove-tabs

View File

@ -34,9 +34,17 @@ class Tea:
tea = Tea() # lazy loader tea = Tea() # lazy loader
@app.command("export-students", help="export students from canvas to csv file") @app.command("export-users", help="export users from canvas to csv file")
def export_students_to_csv(output_file: Path) -> None: def export_users_to_csv(output_file: Path = Argument("students.csv")) -> None:
tea.pot.canvas.export_students_to_csv(output_file) tea.pot.canvas.export_users_to_csv(output_file)
@app.command(
"export-wrong-email-users",
help="export users with wrong email from canvas in stdout",
)
def export_wrong_email_users() -> None:
tea.pot.canvas.export_wrong_email_users()
@app.command( @app.command(
@ -295,6 +303,10 @@ def joj3_all_env(
False, False,
help="skip creating failed table on gitea", help="skip creating failed table on gitea",
), ),
scoreboard_column_by_ref: bool = Option(
False,
help="use git ref as scoreboard column name",
),
submitter_in_issue_title: bool = Option( submitter_in_issue_title: bool = Option(
True, True,
help="whether to include submitter in issue title", help="whether to include submitter in issue title",
@ -307,6 +319,10 @@ def joj3_all_env(
"#795548", "#795548",
help="label color for the issue created by this command", help="label color for the issue created by this command",
), ),
issue_label_exclusive: bool = Option(
False,
help="label set as exclusive for the issue created by this command",
),
end_time: Optional[datetime] = Option(None), end_time: Optional[datetime] = Option(None),
penalty_config: str = Option( penalty_config: str = Option(
"", "",
@ -360,6 +376,7 @@ def joj3_all_env(
submitter_repo_name, submitter_repo_name,
issue_label_name, issue_label_name,
issue_label_color, issue_label_color,
issue_label_exclusive,
penalty_factor, penalty_factor,
) )
res["issue"] = issue_number res["issue"] = issue_number
@ -398,11 +415,15 @@ def joj3_all_env(
raise Exit(code=1) raise Exit(code=1)
repo.git.reset("--hard", "origin/grading") repo.git.reset("--hard", "origin/grading")
if not skip_scoreboard: if not skip_scoreboard:
exercise_name = env.joj3_conf_name
if scoreboard_column_by_ref:
exercise_name = env.github_ref
joj3.generate_scoreboard( joj3.generate_scoreboard(
env.joj3_output_path, env.joj3_output_path,
env.github_actor, env.github_actor,
os.path.join(repo_path, scoreboard_filename), os.path.join(repo_path, scoreboard_filename),
env.joj3_conf_name, exercise_name,
submitter_repo_name,
) )
tea.pot.git.add_commit( tea.pot.git.add_commit(
grading_repo_name, grading_repo_name,
@ -512,6 +533,16 @@ def joj3_check_env(
logger.info("joj3-check-env done") logger.info("joj3-check-env done")
@app.command("joj3-check-gitea-token")
def joj3_check_gitea_token(
env_path: str = Argument("", help="path to .env file")
) -> None:
app.pretty_exceptions_enable = False
set_settings(Settings(_env_file=env_path))
set_logger(settings.stderr_log_level)
tea.pot.gitea.organization_api.org_list_repos(settings.gitea_org_name)
if __name__ == "__main__": if __name__ == "__main__":
try: try:
app() app()

View File

@ -240,6 +240,7 @@ class Teapot:
submitter_repo_name: str, submitter_repo_name: str,
issue_label_name: str, issue_label_name: str,
issue_label_color: str, issue_label_color: str,
issue_label_exclusive: bool,
penalty_factor: float, penalty_factor: float,
) -> int: ) -> int:
title, comment = joj3.generate_title_and_comment( title, comment = joj3.generate_title_and_comment(
@ -280,7 +281,11 @@ class Teapot:
label = self.gitea.issue_api.issue_create_label( label = self.gitea.issue_api.issue_create_label(
self.gitea.org_name, self.gitea.org_name,
submitter_repo_name, submitter_repo_name,
body={"name": issue_label_name, "color": issue_label_color}, body={
"name": issue_label_name,
"color": issue_label_color,
"exclusive": issue_label_exclusive,
},
) )
label_id = label.id label_id = label.id
joj3_issue = self.gitea.issue_api.issue_create_issue( joj3_issue = self.gitea.issue_api.issue_create_issue(
@ -325,7 +330,7 @@ class Teapot:
f"[{end_time + timedelta(seconds=1)}, {penalty_end_time}].\n", f"[{end_time + timedelta(seconds=1)}, {penalty_end_time}].\n",
True, True,
) )
else: elif now > end_time:
return ( return (
"### Submission Time Check Passed\n" "### Submission Time Check Passed\n"
f"Current time {now} is not in the valid range " f"Current time {now} is not in the valid range "
@ -381,11 +386,13 @@ class Teapot:
time_windows.append(since) time_windows.append(since)
valid_items.append((name, max_count, time_period, since)) valid_items.append((name, max_count, time_period, since))
logger.info(f"valid items: {valid_items}, time windows: {time_windows}") logger.info(f"valid items: {valid_items}, time windows: {time_windows}")
all_commits = [] matched_commits = []
all_commits_length = 0
if time_windows: if time_windows:
earliest_since = min(time_windows).strftime("%Y-%m-%dT%H:%M:%S") earliest_since = min(time_windows).strftime("%Y-%m-%dT%H:%M:%S")
commits = repo.iter_commits(paths=scoreboard_filename, since=earliest_since) commits = repo.iter_commits(paths=scoreboard_filename, since=earliest_since)
for commit in commits: for commit in commits:
all_commits_length += 1
lines = commit.message.strip().splitlines() lines = commit.message.strip().splitlines()
if not lines: if not lines:
continue continue
@ -403,17 +410,19 @@ class Teapot:
commit_groups = ( commit_groups = (
groups_line[len("groups: ") :].split(",") if groups_line else [] groups_line[len("groups: ") :].split(",") if groups_line else []
) )
all_commits.append( matched_commits.append(
{ {
"time": commit.committed_datetime, "time": commit.committed_datetime,
"groups": [g.strip() for g in commit_groups], "groups": [g.strip() for g in commit_groups],
} }
) )
logger.info(f"all commits length: {len(all_commits)}") logger.info(
f"matched commits length: {len(matched_commits)}, all commits length: {all_commits_length}"
)
for name, max_count, time_period, since in valid_items: for name, max_count, time_period, since in valid_items:
submit_count = 0 submit_count = 0
time_limit = now - timedelta(hours=time_period) time_limit = now - timedelta(hours=time_period)
for commit in all_commits: for commit in matched_commits:
if commit["time"] < time_limit: if commit["time"] < time_limit:
continue continue
if name: if name:

View File

@ -40,6 +40,7 @@ def generate_scoreboard(
submitter: str, submitter: str,
scoreboard_file_path: str, scoreboard_file_path: str,
exercise_name: str, exercise_name: str,
submitter_repo_name: str,
) -> None: ) -> None:
if not scoreboard_file_path.endswith(".csv"): if not scoreboard_file_path.endswith(".csv"):
logger.error( logger.error(
@ -48,18 +49,25 @@ def generate_scoreboard(
return return
os.makedirs(os.path.dirname(scoreboard_file_path), exist_ok=True) os.makedirs(os.path.dirname(scoreboard_file_path), exist_ok=True)
# Load the csv file if it already exists # Load the csv file if it already exists
fixed_headers = ["", "repo_name", "last_edit", "total"]
fixed_defaults = [submitter, submitter_repo_name, "", "0"]
if os.path.exists(scoreboard_file_path): if os.path.exists(scoreboard_file_path):
with open(scoreboard_file_path, newline="") as file: with open(scoreboard_file_path, newline="") as file:
reader = csv.reader(file) reader = csv.reader(file)
rows = list(reader) rows = list(reader)
columns = rows[0] columns = rows[0]
data = rows[1:] data = rows[1:]
def migrate_scoreboard() -> None:
if "repo_name" in columns:
return
columns.insert(1, "repo_name")
for row in data:
row.insert(1, "")
migrate_scoreboard()
else: else:
columns = [ columns = fixed_headers
"",
"last_edit",
"total",
]
data = [] data = []
submitter_found = False submitter_found = False
@ -69,8 +77,7 @@ def generate_scoreboard(
submitter_found = True submitter_found = True
break break
if not submitter_found: if not submitter_found:
fixed_columns = [submitter, "", "0"] submitter_row = fixed_defaults + [""] * (len(columns) - len(fixed_headers))
submitter_row = fixed_columns + [""] * (len(columns) - len(fixed_columns))
data.append(submitter_row) data.append(submitter_row)
# Update data # Update data
@ -85,9 +92,9 @@ def generate_scoreboard(
exercise_name = comment.split("-")[0] exercise_name = comment.split("-")[0]
# Find if exercise in table: # Find if exercise in table:
if exercise_name not in columns: if exercise_name not in columns:
column_tail = columns[3:] column_tail = columns[len(fixed_headers) :]
bisect.insort(column_tail, exercise_name) bisect.insort(column_tail, exercise_name)
columns[3:] = column_tail columns[len(fixed_headers) :] = column_tail
index = columns.index(exercise_name) index = columns.index(exercise_name)
for row in data: for row in data:
row.insert(index, "") row.insert(index, "")
@ -101,17 +108,22 @@ def generate_scoreboard(
total = 0 total = 0
for col in columns: for col in columns:
if col in ["", "total", "last_edit"]: if col in fixed_headers:
continue continue
idx = columns.index(col) idx = columns.index(col)
if (submitter_row[idx] is not None) and (submitter_row[idx] != ""): if (submitter_row[idx] is not None) and (submitter_row[idx] != ""):
total += int(submitter_row[idx]) try:
total += int(submitter_row[idx])
except ValueError:
pass
submitter_row[columns.index("total")] = str(total) submitter_row[columns.index("total")] = str(total)
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
submitter_row[columns.index("last_edit")] = now submitter_row[columns.index("last_edit")] = now
submitter_row[columns.index("repo_name")] = submitter_repo_name
# Sort data by total, from low to high # Sort data by total, from low to high
data.sort(key=lambda x: int(x[columns.index("total")])) data.sort(key=lambda x: int(x[columns.index("total")]))
@ -236,9 +248,7 @@ def generate_title_and_comment(
"[Joint-Teapot](https://github.com/BoYanZh/Joint-Teapot) with ❤️.\n" "[Joint-Teapot](https://github.com/BoYanZh/Joint-Teapot) with ❤️.\n"
) )
if penalty_factor != 1.0: if penalty_factor != 1.0:
comment += ( comment += f"## ⚠Total Score Penalty Warning⚠\n**The total score is multiplied by {penalty_factor}.**\n"
f"## Total Score Penalty Warning\nThe total score is multiplied by 0.75.\n"
)
for stage in stages: for stage in stages:
if all( if all(
result["score"] == 0 and result["comment"].strip() == "" result["score"] == 0 and result["comment"].strip() == ""
@ -260,7 +270,7 @@ def generate_title_and_comment(
total_score += result["score"] total_score += result["score"]
comment += "\n" comment += "\n"
if penalty_factor != 1.0: if penalty_factor != 1.0:
total_score = round(total_score * penalty_factor) total_score = round(total_score - abs(total_score) * (1 - penalty_factor))
title = get_title_prefix(exercise_name, submitter, submitter_in_title) title = get_title_prefix(exercise_name, submitter, submitter_in_title)
if max_total_score >= 0: if max_total_score >= 0:
title += f"{total_score} / {max_total_score}" title += f"{total_score} / {max_total_score}"
@ -307,8 +317,10 @@ def get_penalty_factor(
) -> float: ) -> float:
if not end_time or not penalty_config: if not end_time or not penalty_config:
return 1.0 return 1.0
penalties = parse_penalty_config(penalty_config)
now = datetime.now() now = datetime.now()
if now < end_time:
return 1.0
penalties = parse_penalty_config(penalty_config)
res = 0.0 res = 0.0
for hour, factor in penalties[::-1]: for hour, factor in penalties[::-1]:
if now < end_time + timedelta(hours=hour): if now < end_time + timedelta(hours=hour):

View File

@ -38,10 +38,7 @@ def set_logger(
) -> None: ) -> None:
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True) logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
logger.remove() logger.remove()
logger.add( logger.add(stderr, level=stderr_log_level, colorize=stderr.isatty())
stderr,
level=stderr_log_level,
)
logger.add(settings.log_file_path, level="DEBUG") logger.add(settings.log_file_path, level="DEBUG")

View File

@ -35,7 +35,7 @@ class Canvas:
# types = ["student", "observer"] # types = ["student", "observer"]
types = ["student"] types = ["student"]
def patch_student(student: User) -> User: def patch_user(student: User) -> User:
student.name = ( student.name = (
re.sub(r"[^\x00-\x7F]+", "", student.name).strip().title() re.sub(r"[^\x00-\x7F]+", "", student.name).strip().title()
) # We only care english name ) # We only care english name
@ -44,7 +44,7 @@ class Canvas:
return student return student
self.students = [ self.students = [
patch_student(student) patch_user(student)
for student in self.course.get_users(enrollment_type=types) for student in self.course.get_users(enrollment_type=types)
] ]
for attr in ["login_id", "name"]: for attr in ["login_id", "name"]:
@ -52,6 +52,7 @@ class Canvas:
raise Exception( raise Exception(
f"Unable to gather students' {attr}, please contact the Canvas site admin" f"Unable to gather students' {attr}, please contact the Canvas site admin"
) )
self.users = [patch_user(student) for student in self.course.get_users()]
logger.debug("Canvas students loaded") logger.debug("Canvas students loaded")
self.assignments = self.course.get_assignments() self.assignments = self.course.get_assignments()
logger.debug("Canvas assignments loaded") logger.debug("Canvas assignments loaded")
@ -60,12 +61,26 @@ class Canvas:
self.grade_filename = grade_filename self.grade_filename = grade_filename
logger.debug("Canvas initialized") logger.debug("Canvas initialized")
def export_students_to_csv(self, filename: Path) -> None: def export_wrong_email_users(self) -> None:
SAMPLE_EMAIL_BODY = """Dear Student,
We have noticed that you have changed your email address on Canvas. While this can clearly cause privacy issues, this also prevents you from joining Gitea which will be intensively used in this course. Please revert back to your SJTU email address (`jaccount@sjtu.edu.cn`) as soon as possible. Note that if your email address is still incorrect in 24 hours, we will have to apply penalties as this is slowing down the whole course progress.
Best regards,
Teaching Team"""
emails = [
user.email for user in self.users if not user.email.endswith("@sjtu.edu.cn")
]
print(f"To: {','.join(emails)}")
print(f"Subject: [{settings.gitea_org_name}] Important: wrong Canvas email")
print(f"Body:\n{SAMPLE_EMAIL_BODY}")
def export_users_to_csv(self, filename: Path) -> None:
with open(filename, mode="w", newline="") as file: with open(filename, mode="w", newline="") as file:
writer = csv.writer(file) writer = csv.writer(file)
for student in self.students: for user in self.users:
writer.writerow([student.name, student.sis_id, student.login_id]) writer.writerow([user.name, user.sis_id, user.login_id])
logger.info(f"Students exported to {filename}") logger.info(f"Users exported to {filename}")
def prepare_assignment_dir( def prepare_assignment_dir(
self, dir_or_zip_file: str, create_grade_file: bool = True self, dir_or_zip_file: str, create_grade_file: bool = True

View File

@ -90,25 +90,23 @@ class Git:
retry_interval = 2 retry_interval = 2
while retry_interval and auto_retry: while retry_interval and auto_retry:
try: try:
current_branch = ""
if repo.head.is_detached:
current_branch = repo.head.commit.hexsha
else:
current_branch = repo.active_branch.name
if clean_git_lock: if clean_git_lock:
lock_files = [ locks_removed_count = 0
"index.lock", for root, _, files in os.walk(os.path.join(repo_dir, ".git")):
"HEAD.lock", for filename in files:
"fetch-pack.lock", if filename.endswith(".lock"):
"logs/HEAD.lock", lock_file_path = os.path.join(root, filename)
"packed-refs.lock", if (
"config.lock", os.path.join(".git", filename)
f"refs/remotes/origin/{current_branch}.lock", == settings.joj3_lock_file_path
] ):
for lock_file in lock_files: continue
lock_path = os.path.join(repo_dir, ".git", lock_file) try:
if os.path.exists(lock_path): os.remove(lock_file_path)
os.remove(lock_path) locks_removed_count += 1
except OSError as e:
logger.warning(f"Error removing lock file: {e}")
logger.info(f"Removed {locks_removed_count} lock files")
repo.git.fetch("--tags", "--all", "-f") repo.git.fetch("--tags", "--all", "-f")
repo.git.reset("--hard", reset_target) repo.git.reset("--hard", reset_target)
repo.git.clean("-d", "-f", "-x") repo.git.clean("-d", "-f", "-x")

View File

@ -494,6 +494,7 @@ class Gitea:
self.repository_api.user_current_delete_subscription( self.repository_api.user_current_delete_subscription(
self.org_name, repo.name self.org_name, repo.name
) )
logger.info(f"Unwatched {repo.name}")
def get_all_teams(self) -> Dict[str, List[str]]: def get_all_teams(self) -> Dict[str, List[str]]:
res: Dict[str, List[str]] = {} res: Dict[str, List[str]] = {}