diff --git a/Timeline.py b/Timeline.py
new file mode 100644
index 0000000000000000000000000000000000000000..411cfa985bb0ca4dc55bc5f8d8bb303c3e945bbb
--- /dev/null
+++ b/Timeline.py
@@ -0,0 +1,77 @@
+import json
+from pathlib import Path
+
+from api.gitlab_classes import *
+from course import Course
+
+
+class Timeline:
+    # noinspection PyShadowingNames
+    def __init__(self, project, filename):
+        self.project = project
+        self.save_file = filename
+        if Path(self.save_file).exists():
+            with open(self.save_file, mode='r') as json_file:
+                this_dict = json.load(json_file)
+            self.start_time = this_dict['start_time']
+            self.issues = this_dict['issues']
+            self.observational_periods = this_dict['observational_periods']
+        else:
+            self.project = project
+            # self.commits = project.get_commits()  # we'll worry about commits later
+            self.start_time = datetime.now().isoformat()
+            self.observational_periods = []
+            self.issues = []
+        self.observational_periods.append({'from': datetime.now().isoformat(), 'to': datetime.now().isoformat()})
+        self.issues = list(map(lambda issue: self.update_issue_timeline(issue), project.get_issues()))
+
+    def save(self):
+        this_dict = {'project': self.project.get_path_with_namespace(),
+                     'issues': self.issues,
+                     'start_time': self.start_time,
+                     'observational_periods': self.observational_periods
+                     }
+        path = Path(self.save_file)
+        backup_path = Path(f'{self.save_file}.bk')
+        if path.exists():
+            path.rename(backup_path)
+        with open(self.save_file, mode='w') as json_file:
+            json.dump(this_dict, json_file, indent=4)
+
+    def update_issue_timeline(self, issue):
+        possible_issue_timeline = list(filter(lambda i: i['number'] == issue.get_project_issue_id(), self.issues))
+        if len(possible_issue_timeline) == 0:
+            issue_timeline = {'number': issue.get_project_issue_id(),
+                              'opened': issue.get_created_at().isoformat(),
+                              'events': []}
+            old_labels = set()
+        else:
+            issue_timeline = possible_issue_timeline[0]
+            old_labels = set(issue_timeline['current_labels'])
+        new_labels = issue.get_labels()
+        all_labels = old_labels.union(new_labels)
+        for label in all_labels:
+            if label in old_labels.difference(new_labels):
+                issue_timeline['events'].append(('Remove Label', label, datetime.now().isoformat()))
+            if label in new_labels.difference(old_labels):
+                issue_timeline['events'].append(('Add Label', label, datetime.now().isoformat()))
+        issue_timeline['current_labels'] = list(issue.get_labels())
+        if issue.get_closed_at() is not None:
+            issue_timeline['closed'] = issue.get_closed_at().isoformat()
+        else:
+            issue_timeline['closed'] = None
+        return issue_timeline
+
+
+if __name__ == '__main__':
+    # A handy project to work from for now
+    projects = GitlabProject.get_projects_by_group(Course.gitlab_namespace)
+    project = list(filter(lambda p: p.get_name() == 'Chess 4', projects))[0]
+    print(project)
+    timeline = Timeline(project, 'chess4.json')
+    #    print(len(project.get_commits(branch_name = 'staging')))
+    #    print(len(project.git_project.commits.list(all=True)))
+    timeline.save()
+    new_timeline = Timeline(project, 'chess4.json')
+    print(new_timeline.project)
+    new_timeline.save()
diff --git a/api/gitlab_classes.py b/api/gitlab_classes.py
index 4c2fd4ef2e4362509437fee8bceeed60f71e23fa..9b12b76265d8a629b7909bad5e40a1ec7c0449b3 100644
--- a/api/gitlab_classes.py
+++ b/api/gitlab_classes.py
@@ -1,6 +1,7 @@
 from datetime import datetime
-from datetime import timezone
+
 import gitlab
+
 from config import Config
 
 
@@ -22,9 +23,9 @@ class GitlabUser:
         the username
         """
         super().__init__()
-        if isinstance(user, int):               # by user id
+        if isinstance(user, int):  # by user id
             self.git_user = GitlabSession.get_session().users.get(user)
-        elif isinstance(user, str):             # by username
+        elif isinstance(user, str):  # by username
             self.git_user = GitlabSession.get_session().users.list(username=user)[0]
         else:
             self.git_user = user
@@ -99,7 +100,7 @@ class GitlabIssue:
         :return: an "aware" datetime object representing the creation date/time
         """
         created_at = self.git_issue.created_at
-        if created_at[-1] in ('z', 'Z'):        # Didn't encounter this problem with created_at
+        if created_at[-1] in ('z', 'Z'):  # Didn't encounter this problem with created_at
             created_at = created_at[:-1] + '+00:00'
         return datetime.fromisoformat(created_at)
 
@@ -108,7 +109,7 @@ class GitlabIssue:
         :return: an "aware" datetime object representing the last date/time the issue was updated
         """
         updated_at = self.git_issue.updated_at
-        if updated_at[-1] in ('z', 'Z'):        # Didn't encounter this problem with updated_at
+        if updated_at[-1] in ('z', 'Z'):  # Didn't encounter this problem with updated_at
             updated_at = updated_at[:-1] + '+00:00'
         return datetime.fromisoformat(updated_at)
 
@@ -121,15 +122,16 @@ class GitlabIssue:
         if closed_at is None:
             return None
         else:
-            if closed_at[-1] in ('z', 'Z'):     # Did encounter this problem with closed_at
+            if closed_at[-1] in ('z', 'Z'):  # Did encounter this problem with closed_at
                 closed_at = closed_at[:-1] + '+00:00'
             return datetime.fromisoformat(closed_at)
 
     def get_labels(self):
         """
-        :return: list of labels
+        :return: set of label names
         """
-        return self.git_issue.labels.list(all=True)
+        return set(self.git_issue.labels)
+        # return self.git_issue.labels.list(all=True)
 
     def get_page(self):
         """
@@ -169,6 +171,7 @@ class GitlabIssue:
 
 
 class GitlabCommit:
+    # noinspection PyShadowingNames
     def __init__(self, commit):
         super().__init__()
         self.gitlab_commit = commit
@@ -185,6 +188,7 @@ class GitlabCommit:
     def is_merge(self):
         return len(self.gitlab_commit.parent_ids) > 1
 
+    # noinspection PyShadowingNames
     def get_diffs(self):
         diffs = []
         gitlab_diffs = self.gitlab_commit.diff()
@@ -193,6 +197,7 @@ class GitlabCommit:
                           '+': diff['diff'].count('\n+'), '-': diff['diff'].count('\n-')})
         return diffs
 
+    # noinspection PyShadowingNames
     def get_diff_size(self):
         insertions = 0
         deletions = 0
@@ -231,9 +236,9 @@ class GitlabProject:
         containing the project's path (namespace and name, such as 'csce_361/sandbox/HelloWorld')
         """
         super().__init__()
-        if isinstance(project, int):            # by project id
+        if isinstance(project, int):  # by project id
             self.git_project = GitlabSession.get_session().projects.get(project)
-        elif isinstance(project, str):          # by path
+        elif isinstance(project, str):  # by path
             self.git_project = GitlabSession.get_session().projects.get(project)
         else:
             # self.git_project = project        # for some reason, many attributes (including members) might be lost
@@ -245,7 +250,7 @@ class GitlabProject:
         :param group: must be either an integer representing the group ID, or a string containing the group's namespace
         :return: list of projects in the specified group
         """
-        if isinstance(group, int):              # by group id
+        if isinstance(group, int):  # by group id
             gitlab_projects = GitlabSession.get_session().groups.get(group).projects.list(all=True)
         else:  # isinstance(group, str):        # by path
             gitlab_projects = GitlabSession.get_session().groups.get(group).projects.list(all=True)
@@ -375,10 +380,11 @@ class GitlabProject:
         gitlab_issue = self.git_project.issues.create({'title': title, 'description': description})
         return GitlabIssue(gitlab_issue)
 
-    def get_commits(self, branch_name = '', after_date ='1970-01-01', before_date ='9999-12-31'):
+    # noinspection PyShadowingNames
+    def get_commits(self, branch_name='', after_date='1970-01-01', before_date='9999-12-31'):
         """
         :param branch_name: the branch to retrieve commits from; if an empty string (default) then retrieves commits
-        from all branches
+        from all branches <-- NO, RETRIEVES FROM DEFAULT BRANCH; WILL NEED TO FIX THAT #TODO
         :param after_date: the earliest date of any retrieved commit; if '1970-01-01' (Unix epoch) then treated as
         having no earliest-bound
         :param before_date: the latest date of any retrieved commit; if '9999-12-31' (Y10K problem) then treated a
@@ -401,6 +407,12 @@ class GitlabProject:
             commits.append(GitlabCommit(commit))
         return commits
 
+    def get_labels(self):
+        """
+        :return: set of label names
+        """
+        return set(map(lambda label: label.name, self.git_project.labels.list()))
+
     def __repr__(self):
         return self.get_name_with_namespace()
 
diff --git a/prep_assignment.py b/prep_assignment.py
index f9d5ab4830aa1dedaef09d871b72a85f8cf29308..bfa6094d64e9ec86241b76927854005086f190f7 100644
--- a/prep_assignment.py
+++ b/prep_assignment.py
@@ -81,7 +81,7 @@ def create_groups(assignment_number, student_pairs):
 
 
 if __name__ == '__main__':
-    assignment = '21b'
+    assignment = '28'
     pairs = create_pairs('2019-08.csv')
     save_pairs(assignment, pairs)
     print('Pairs created')
@@ -94,3 +94,25 @@ if __name__ == '__main__':
     print('TODO:\tAdd issues')
     print('\tCommit starter code')
     print('\tUpdate graylists (also, please update the code to update the graylists)')
+
+"""
+had a graylist violation on 28pair 4
+
+encountered this on 28pair 18:
+
+    Traceback (most recent call last):
+      File "/Users/cabohn/courses/csce361/scripts/prep_assignment.py", line 91, in <module>
+        create_groups(assignment, pairs)
+      File "/Users/cabohn/courses/csce361/scripts/prep_assignment.py", line 79, in create_groups
+        group.add_student(pair[1].get_canvas_user())
+      File "/Users/cabohn/courses/csce361/scripts/api/composite_user.py", line 33, in get_canvas_user
+        self.canvas_user = CanvasUser(self.NUID)    # n.b., can retrieve own user but not arbitrary user
+      File "/Users/cabohn/courses/csce361/scripts/api/canvas_classes.py", line 22, in __init__
+        self.canvas_user = CanvasSession.get_session().get_user(user, 'sis_user_id')
+      File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/canvasapi/canvas.py", line 1110, in get_user
+        response = self.__requester.request("GET", uri)
+      File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/canvasapi/requester.py", line 227, in request
+        raise Unauthorized(response.json())
+    canvasapi.exceptions.Unauthorized: [{'message': 'user not authorized to perform that action'}]
+
+"""
\ No newline at end of file