Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions config/urls.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"fmt"
"net/url"
"strconv"
"strings"
)

Expand Down Expand Up @@ -71,13 +72,58 @@ func gitURLToHTTPSBase(raw string) (string, error) {
func (cfg *AssignmentConfig) Urls(assignment bool) {
if assignment {
fmt.Println(cfg.URL)
} else if cfg.Per == PerStudent {
return
}
for _, r := range cfg.RepoURLs() {
fmt.Println(r.URL)
}
}

// RepoURL is one repository URL together with who it belongs to: a student's
// email (or username/id fallback) for per-student assignments, or the group
// name for per-group assignments.
type RepoURL struct {
For string
URL string
}

// RepoURLs returns the per-student or per-group repository URLs for the
// assignment (depending on Per). The assignment-level group URL is cfg.URL.
// It is the data behind the printing Urls method, reusable by the web layer.
func (cfg *AssignmentConfig) RepoURLs() []RepoURL {
var out []RepoURL
if cfg.Per == PerStudent {
for _, stud := range cfg.Students {
fmt.Printf("%s/%s\n", cfg.URL, cfg.RepoNameForStudent(stud))
out = append(out, RepoURL{
For: studentLabel(stud),
URL: cfg.URL + "/" + cfg.RepoNameForStudent(stud),
})
}
} else { // PerGroup
for _, group := range cfg.Groups {
fmt.Printf("%s/%s\n", cfg.URL, cfg.RepoNameForGroup(group))
out = append(out, RepoURL{
For: group.Name,
URL: cfg.URL + "/" + cfg.RepoNameForGroup(group),
})
}
}
return out
}

// studentLabel is a human-readable identifier for a student: the email, else the
// username, else the raw roster entry, else the numeric id.
func studentLabel(s *Student) string {
if s.Email != nil && *s.Email != "" {
return *s.Email
}
if s.Username != nil && *s.Username != "" {
return *s.Username
}
if s.Raw != "" {
return s.Raw
}
if s.Id != nil {
return strconv.Itoa(*s.Id)
}
return ""
}
49 changes: 49 additions & 0 deletions config/urls_show_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,55 @@ func TestUrls_PerGroup(t *testing.T) {
}
}

func TestRepoURLs_PerStudent_PrefersEmailLabel(t *testing.T) {
mail := "a@hm.edu"
user := "bob"
cfg := &AssignmentConfig{
URL: "https://gitlab.example.org/mpd/ss26/blatt-01",
Name: "blatt01",
Course: "mpd",
UseCoursenameAsPrefix: true,
Per: PerStudent,
Students: []*Student{
{Email: &mail, Raw: "a@hm.edu"},
{Username: &user, Raw: "bob"},
},
}
got := cfg.RepoURLs()
if len(got) != 2 {
t.Fatalf("RepoURLs() len = %d, want 2", len(got))
}
if got[0].For != "a@hm.edu" {
t.Errorf("For[0] = %q, want the email", got[0].For)
}
if got[1].For != "bob" {
t.Errorf("For[1] = %q, want the username fallback", got[1].For)
}
want := cfg.URL + "/" + cfg.RepoNameForStudent(cfg.Students[0])
if got[0].URL != want {
t.Errorf("URL[0] = %q, want %q", got[0].URL, want)
}
}

func TestRepoURLs_PerGroup(t *testing.T) {
cfg := &AssignmentConfig{
URL: "https://gitlab.example.org/mpd/ss26/blatt-01",
Name: "blatt01",
Course: "mpd",
UseCoursenameAsPrefix: true,
Per: PerGroup,
Groups: []*Group{{Name: "team1"}, {Name: "team2"}},
}
got := cfg.RepoURLs()
if len(got) != 2 || got[0].For != "team1" || got[1].For != "team2" {
t.Fatalf("RepoURLs() = %+v, want two groups team1/team2", got)
}
want := cfg.URL + "/" + cfg.RepoNameForGroup(cfg.Groups[1])
if got[1].URL != want {
t.Errorf("URL[1] = %q, want %q", got[1].URL, want)
}
}

func TestShow_Minimal(t *testing.T) {
cfg := &AssignmentConfig{
Course: "mpd",
Expand Down
57 changes: 57 additions & 0 deletions web/app/urls.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package app

import (
"context"

"github.com/obcode/glabs/v3/config"
)

// AssignmentURLs are the repository URLs for one assignment: the assignment-level
// group URL plus one entry per student or per group. Derived purely from the
// resolved configuration — no GitLab token or API call is involved.
type AssignmentURLs struct {
// Per is "student" or "group".
Per string
// GroupURL is the assignment-level group URL where all the repos live.
GroupURL string
// Repos is one URL per student/group repository.
Repos []config.RepoURL
}

// AssignmentURLs returns the repository URLs for one assignment of one of the
// caller's own courses. It returns nil (no error) when the course has no such
// assignment, or when the assignment cannot be resolved (e.g. an abstract base) —
// in both cases there simply are no URLs. ErrCourseNotFound propagates when the
// course itself is not the caller's.
func (a *App) AssignmentURLs(ctx context.Context, course, name string) (*AssignmentURLs, error) {
stored, err := a.Course(ctx, course)
if err != nil {
return nil, err
}

src, ok := stored.Source.Assignments[name]
if !ok || src == nil {
return nil, nil
}

// Resolve from the stored bytes so the URLs match the CLI exactly (the same
// inheritance, roster resolution and path normalization). Fall back to
// re-encoding the source if the raw bytes are absent.
bytes := stored.RawYAML
if len(bytes) == 0 {
if encoded, err := config.EncodeCourse(stored.Source); err == nil {
bytes = encoded
}
}
cfg, err := config.ResolveAssignmentFromBytes(bytes, course, name, config.Globals{GitlabHost: a.gitlabHost})
if err != nil {
// Not resolvable (abstract base, missing parent, cycle) → no URLs.
return nil, nil
}

return &AssignmentURLs{
Per: string(cfg.Per),
GroupURL: cfg.URL,
Repos: cfg.RepoURLs(),
}, nil
}
82 changes: 82 additions & 0 deletions web/app/urls_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package app

import (
"strings"
"testing"
)

const urlsCourse = `uc:
coursepath: uc/sem
semesterpath: ss26
usecoursenameasprefix: true
blatt1:
per: student
accesslevel: developer
students:
- a@hm.edu
- b@hm.edu
`

func TestAssignmentURLs_PerStudent(t *testing.T) {
const owner = "prof@hm.edu"
fs := newFakeStore()
fs.courses[owner+"/uc"] = storedCourse(t, owner, urlsCourse)
a := &App{db: fs, gitlabHost: "https://gitlab.example"}
ctx := ctxAs(owner)

u, err := a.AssignmentURLs(ctx, "uc", "blatt1")
if err != nil {
t.Fatalf("AssignmentURLs: %v", err)
}
if u == nil {
t.Fatal("expected URLs for blatt1")
}
if u.Per != "student" {
t.Errorf("Per = %q, want student", u.Per)
}
if !strings.HasPrefix(u.GroupURL, "https://gitlab.example/") {
t.Errorf("GroupURL = %q, want it under the gitlab host", u.GroupURL)
}
if len(u.Repos) != 2 {
t.Fatalf("Repos len = %d, want 2", len(u.Repos))
}
for _, r := range u.Repos {
if r.For == "" {
t.Errorf("repo For is empty: %+v", r)
}
if !strings.HasPrefix(r.URL, u.GroupURL+"/") {
t.Errorf("repo URL %q is not under the group URL %q", r.URL, u.GroupURL)
}
}
}

func TestAssignmentURLs_missingAssignmentReturnsNil(t *testing.T) {
const owner = "prof@hm.edu"
fs := newFakeStore()
fs.courses[owner+"/uc"] = storedCourse(t, owner, urlsCourse)
a := &App{db: fs, gitlabHost: "https://gitlab.example"}

u, err := a.AssignmentURLs(ctxAs(owner), "uc", "nope")
if err != nil {
t.Fatalf("AssignmentURLs: %v", err)
}
if u != nil {
t.Errorf("expected nil for a missing assignment, got %+v", u)
}
}

func TestAssignmentURLs_abstractBaseReturnsNil(t *testing.T) {
const owner = "prof@hm.edu"
fs := newFakeStore()
fs.courses[owner+"/tc"] = storedCourse(t, owner, tcCourse)
a := &App{db: fs, gitlabHost: "https://gl"}

// An abstract base does not resolve → no URLs (nil, no error).
u, err := a.AssignmentURLs(ctxAs(owner), "tc", "base")
if err != nil {
t.Fatalf("AssignmentURLs: %v", err)
}
if u != nil {
t.Errorf("expected nil for an abstract base, got %+v", u)
}
}
10 changes: 10 additions & 0 deletions web/graph/assignment_mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,16 @@ func toGraphValidationResult(vr *app.ValidationResult) *model.ValidationResult {
}
}

// toGraphAssignmentURLs projects the assignment repository URLs onto the GraphQL
// model.
func toGraphAssignmentURLs(u *app.AssignmentURLs) *model.AssignmentUrls {
repos := make([]*model.RepoURL, 0, len(u.Repos))
for _, r := range u.Repos {
repos = append(repos, &model.RepoURL{For: r.For, URL: r.URL})
}
return &model.AssignmentUrls{Per: u.Per, GroupURL: u.GroupURL, Repos: repos}
}

// toGraphAssignmentView projects an assignment view onto the GraphQL model.
func toGraphAssignmentView(view *app.AssignmentView) *model.AssignmentView {
own := make([]*model.FieldValue, 0, len(view.Own))
Expand Down
24 changes: 24 additions & 0 deletions web/graph/assignments.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,30 @@ extend type Query {
assignment(course: String!, name: String!): AssignmentView
"Validate a draft assignment against the real resolver without saving."
validateAssignmentDraft(course: String!, name: String!, draft: [FieldValueInput!]!): ValidationResult!
"The repository URLs for one assignment, derived from the resolved config (no GitLab token needed). Null when there is no such assignment or it cannot be resolved (e.g. an abstract base)."
assignmentUrls(course: String!, name: String!): AssignmentUrls
}

"""
The repository URLs for one assignment: the assignment-level group URL plus one
URL per student or per group. Read-only and derived purely from the resolved
configuration — no GitLab token or API call is involved.
"""
type AssignmentUrls {
"Whether repos are per student or per group (`student` or `group`)."
per: String!
"The assignment-level group URL, where all the repos live."
groupUrl: String!
"One entry per student/group repository."
repos: [RepoUrl!]!
}

"One repository URL together with who it belongs to."
type RepoUrl {
"The student's email (or username/id fallback) or the group's name."
for: String!
"The full web URL of the repository."
url: String!
}

extend type Mutation {
Expand Down
15 changes: 15 additions & 0 deletions web/graph/assignments.resolvers.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading