Skip to content

Commit d76f2b2

Browse files
jon-bellclaude
andcommitted
Add 'no_submission' repo mode for manually-graded assignments
Fifth value on assignment_repo_mode for assignments with no git repository and no student-uploaded artifact (e.g. presentations, oral exams). The grading flow still needs a submission row to attach reviews to, so add an instructor-only create_manual_submission RPC that produces a stub (repository=null, sha=null, submitted_via='manual'); idempotent per profile/group. create_no_repo_submission keeps rejecting non-'none' modes so students can't upload for no_submission assignments. Handout / per-student repo creation paths treat no_submission like 'none' (skip), branch protection is disallowed by the existing constraint extended to cover both modes, and the prior-assignment dropdown filters out both modes since neither has repos to fork. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 5263333 commit d76f2b2

15 files changed

Lines changed: 253 additions & 31 deletions

File tree

app/course/[course_id]/manage/assignments/new/form.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -555,19 +555,21 @@ function RepositoryConfigurationSubform({ form }: { form: UseFormReturnType<Assi
555555
const requirePR = watch("protect_require_pull_request") ?? false;
556556

557557
// Source assignment options for the fork-from-prior mode (issue #700).
558-
// Exclude the current assignment when editing to prevent self-references.
558+
// Exclude the current assignment when editing to prevent self-references,
559+
// and exclude any assignment that doesn't have per-student repos to fork.
559560
const currentId = form.getValues("id");
560561
const { data: priorAssignments } = useList<Assignment>({
561562
resource: "assignments",
562563
queryOptions: { enabled: !!course_id && repoMode === "fork_from_prior_assignment" },
563564
filters: [
564565
{ field: "class_id", operator: "eq", value: Number.parseInt(course_id as string) },
565-
{ field: "repo_mode", operator: "ne", value: "none" }
566+
{ field: "repo_mode", operator: "nin", value: ["none", "no_submission"] }
566567
],
567568
pagination: { pageSize: 1000 }
568569
});
569570

570-
const protectionDisabled = repoMode === "none";
571+
// Branch protection only makes sense when a repository is actually created.
572+
const protectionDisabled = repoMode === "none" || repoMode === "no_submission";
571573

572574
return (
573575
<CardRoot>
@@ -593,6 +595,9 @@ function RepositoryConfigurationSubform({ form }: { form: UseFormReturnType<Assi
593595
Fork from prior assignment — multi-checkpoint workflow
594596
</option>
595597
<option value="none">No repository — students upload submission files directly</option>
598+
<option value="no_submission">
599+
No submission — graded manually, no artifact (e.g. presentations, oral exams)
600+
</option>
596601
</NativeSelectField>
597602
</NativeSelectRoot>
598603
</Field>
@@ -632,7 +637,9 @@ function RepositoryConfigurationSubform({ form }: { form: UseFormReturnType<Assi
632637
</Text>
633638
<Text fontSize="sm" color="fg.muted" mb={3}>
634639
{protectionDisabled
635-
? "Branch protection is unavailable when the assignment has no repository."
640+
? repoMode === "no_submission"
641+
? "Branch protection is unavailable: this assignment has no repository and no student submission."
642+
: "Branch protection is unavailable when the assignment has no repository."
636643
: "Rules applied to the default branch of every student/group repository for this assignment."}
637644
</Text>
638645
<Fieldset.Content>

lib/edgeFunctions.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,32 @@ export async function createNoRepoSubmission(
158158
return data as number;
159159
}
160160

161+
/**
162+
* Create an instructor-authored stub submission for an assignment with
163+
* repo_mode='no_submission' (e.g. presentations / oral exams). Returns the
164+
* submission id — either the newly-created one or, if a manual submission was
165+
* already active for that profile/group, the existing one.
166+
*/
167+
export async function createManualSubmission(
168+
params: { assignment_id: number; profile_id?: string; assignment_group_id?: number },
169+
supabase: SupabaseClient<Database>
170+
): Promise<number> {
171+
const { data, error } = await (supabase.rpc as CallableFunction)("create_manual_submission", {
172+
p_assignment_id: params.assignment_id,
173+
p_profile_id: params.profile_id ?? null,
174+
p_assignment_group_id: params.assignment_group_id ?? null
175+
});
176+
if (error) {
177+
Sentry.captureException(error);
178+
throw new EdgeFunctionError({
179+
details: error.message,
180+
message: "Failed to create manual submission",
181+
recoverable: false
182+
});
183+
}
184+
return data as number;
185+
}
186+
161187
export async function activateSubmission(params: { submission_id: number }, supabase: SupabaseClient<Database>) {
162188
const ret = await supabase.rpc("submission_set_active", { _submission_id: params.submission_id });
163189
if (ret.data) {

supabase/functions/_shared/SupabaseTypes.d.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12773,7 +12773,8 @@ export type Database = {
1277312773
| "none"
1277412774
| "template_only_staff"
1277512775
| "template_with_student_forks"
12776-
| "fork_from_prior_assignment";
12776+
| "fork_from_prior_assignment"
12777+
| "no_submission";
1277712778
day_of_week: "sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday";
1277812779
discord_channel_type:
1277912780
| "general"
@@ -12975,7 +12976,8 @@ export const Constants = {
1297512976
"none",
1297612977
"template_only_staff",
1297712978
"template_with_student_forks",
12978-
"fork_from_prior_assignment"
12979+
"fork_from_prior_assignment",
12980+
"no_submission"
1297912981
],
1298012982
day_of_week: ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"],
1298112983
discord_channel_type: [

supabase/functions/_shared/handoutRepoStrategy.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ export function resolveHandoutRepoAction(
4646
source: HandoutSourceAssignment | null
4747
): HandoutRepoAction {
4848
const mode: AssignmentRepoMode = assignment.repo_mode;
49-
if (mode === "none") {
49+
// 'none' (upload) and 'no_submission' (no artifact) both opt out of any
50+
// handout repo on GitHub.
51+
if (mode === "none" || mode === "no_submission") {
5052
return { kind: "noop" };
5153
}
5254
if (mode === "template_only_staff") {

supabase/functions/_shared/repoCreationStrategy.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ export type AssignmentRepoMode =
99
| "none"
1010
| "template_only_staff"
1111
| "template_with_student_forks"
12-
| "fork_from_prior_assignment";
12+
| "fork_from_prior_assignment"
13+
| "no_submission";
1314

1415
export type AssignmentForRepoCreation = {
1516
id: number;
@@ -70,6 +71,9 @@ export function resolveRepoCreationStrategy(
7071
): RepoCreationStrategy {
7172
switch (assignment.repo_mode) {
7273
case "none":
74+
case "no_submission":
75+
// Neither mode creates per-student repos. 'none' lets students upload
76+
// submission files; 'no_submission' has no student artifact at all.
7377
return { kind: "skip", reason: "no_repo_mode" };
7478

7579
case "template_only_staff":

supabase/functions/assignment-create-all-repos/index.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,9 +203,10 @@ export async function createAllRepos(courseId: number, assignmentId: number, sco
203203
scope.setTag("template_repo", assignment.template_repo || "none");
204204
scope.setTag("repo_mode", assignment.repo_mode || "template_only_staff");
205205

206-
// Mode 'none' has no per-student repos to create.
207-
if (assignment.repo_mode === "none") {
208-
console.log("Assignment has repo_mode=none; skipping per-student repo creation");
206+
// Modes 'none' (upload) and 'no_submission' (manual grading, no artifact)
207+
// have no per-student repos to create.
208+
if (assignment.repo_mode === "none" || assignment.repo_mode === "no_submission") {
209+
console.log(`Assignment has repo_mode=${assignment.repo_mode}; skipping per-student repo creation`);
209210
return;
210211
}
211212

supabase/functions/assignment-create-handout-repo/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ async function handleRequest(req: Request, scope: Sentry.Scope) {
4444
throw new UserVisibleError("Class does not have a slug", 400);
4545
}
4646
const handoutRepoOrg = assignment.classes.github_org;
47-
if (!handoutRepoOrg && assignment.repo_mode !== "none") {
47+
if (!handoutRepoOrg && assignment.repo_mode !== "none" && assignment.repo_mode !== "no_submission") {
4848
throw new UserVisibleError("Class does not have a GitHub organization", 400);
4949
}
5050
scope.setTag("repo_mode", assignment.repo_mode);
@@ -72,8 +72,8 @@ async function handleRequest(req: Request, scope: Sentry.Scope) {
7272
);
7373

7474
if (action.kind === "noop") {
75-
// repo_mode === "none". Clear template_repo so downstream consumers don't
76-
// try to use a stale value, and skip GitHub entirely.
75+
// repo_mode in ('none', 'no_submission'). Clear template_repo so downstream
76+
// consumers don't try to use a stale value, and skip GitHub entirely.
7777
if (assignment.template_repo) {
7878
await adminSupabase.from("assignments").update({ template_repo: null }).eq("id", assignment_id);
7979
}

supabase/functions/autograder-create-repos-for-student/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@ async function handleRequest(req: Request, scope: Sentry.Scope) {
348348
const assignments = allAssignments.filter(
349349
(a) =>
350350
a.repo_mode !== "none" &&
351+
a.repo_mode !== "no_submission" &&
351352
a.template_repo?.includes("/") &&
352353
((a.release_date && new TZDate(a.release_date, a.classes.time_zone!) < TZDate.tz(a.classes.time_zone!)) ||
353354
a.classes.user_roles.some((r) => r.role === "instructor" || r.role === "grader")) &&

supabase/migrations/20260522130000_assignment-repo-config.sql

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,29 @@
11
-- Issues #698, #699, #700: Unified per-assignment student-repository configuration.
22
--
3-
-- * repo_mode picks one of four strategies for how student repos relate to a
4-
-- handout (or whether there is a repo at all).
3+
-- * repo_mode picks one of five strategies for how student repos relate to a
4+
-- handout (or whether there is a repo / submission at all).
55
-- * source_assignment_id is required only for the "fork from prior assignment"
66
-- mode (#700) — students get a fork of their own prior repo.
77
-- * protect_* columns map 1:1 to GitHub branch-protection ruleset rules
88
-- applied on the default branch of every repo for this assignment (#698).
99
-- * Existing rows are backfilled implicitly via the column defaults — they
1010
-- keep the current behavior (template-only, staff-only, block force push,
1111
-- block deletion).
12+
--
13+
-- The two no-repo modes:
14+
-- * 'none' — no git repository, but students upload submission files
15+
-- directly via storage (see create_no_repo_submission).
16+
-- * 'no_submission' — no git repository AND no student-uploaded artifact
17+
-- (e.g. presentations, oral exams). Submissions are created by
18+
-- instructors via create_manual_submission so the grading flow still has
19+
-- a row to attach reviews to.
1220

1321
create type public.assignment_repo_mode as enum (
1422
'none',
1523
'template_only_staff',
1624
'template_with_student_forks',
17-
'fork_from_prior_assignment'
25+
'fork_from_prior_assignment',
26+
'no_submission'
1827
);
1928

2029
alter table public.assignments
@@ -31,7 +40,7 @@ alter table public.assignments
3140
or (repo_mode <> 'fork_from_prior_assignment' and source_assignment_id is null)
3241
),
3342
add constraint assignments_no_protection_when_no_repo check (
34-
repo_mode <> 'none' or (
43+
repo_mode not in ('none', 'no_submission') or (
3544
protect_block_force_push = false
3645
and protect_require_pull_request = false
3746
and protect_required_reviewers = 0
@@ -82,7 +91,7 @@ alter table public.submissions alter column sha drop not null;
8291

8392
-- Comment on the new columns so the generated TS types carry intent.
8493
comment on column public.assignments.repo_mode is
85-
'How student repositories relate to the handout: none, template_only_staff, template_with_student_forks, or fork_from_prior_assignment.';
94+
'How student repositories relate to the handout: none (no repo, upload-based submission), template_only_staff, template_with_student_forks, fork_from_prior_assignment, or no_submission (no repo and no student-uploaded artifact; instructor creates submissions for manual grading).';
8695
comment on column public.assignments.source_assignment_id is
8796
'When repo_mode = fork_from_prior_assignment, the assignment whose per-student/group repos are forked to create this assignment''s repos.';
8897
comment on column public.assignments.protect_block_force_push is
@@ -92,4 +101,4 @@ comment on column public.assignments.protect_require_pull_request is
92101
comment on column public.assignments.protect_required_reviewers is
93102
'GitHub ruleset: minimum required approving reviews on the pull request (only enforced when protect_require_pull_request is true).';
94103
comment on column public.assignments.submitted_via is
95-
'Submission origin marker: null/git for repo-pushed submissions, "upload" for no-repo file uploads. Used by graders to route processing.';
104+
'Submission origin marker: null/git for repo-pushed submissions, "upload" for no-repo file uploads, "manual" for instructor-created stubs on no_submission assignments. Used by graders to route processing.';

supabase/migrations/20260522130001_assignment-repo-config-enqueue.sql

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,8 @@ begin
183183
raise exception 'Invalid class/assignment (class_id %, assignment_id %)', course_id, assignment_id;
184184
end if;
185185

186-
if v_repo_mode = 'none' then
187-
raise notice 'Assignment % has repo_mode=none; nothing to enqueue', v_assignment_id;
186+
if v_repo_mode in ('none', 'no_submission') then
187+
raise notice 'Assignment % has repo_mode=%; nothing to enqueue', v_assignment_id, v_repo_mode;
188188
return;
189189
end if;
190190

@@ -377,7 +377,7 @@ begin
377377
join public.user_roles ur on ur.class_id = c.id
378378
where ur.user_id = v_user_id
379379
and (v_class_id is null or c.id = v_class_id)
380-
and a.repo_mode <> 'none'
380+
and a.repo_mode not in ('none', 'no_submission')
381381
and a.group_config <> 'groups'
382382
and (
383383
a.repo_mode = 'fork_from_prior_assignment'

0 commit comments

Comments
 (0)