Merge changes I8b30dbdf,I32736bca,Ifd81ea7a,I504dfe7b,I96d490b4, ...

* changes:
  changelog-webapp: Fixed changelog form alignment
  changelog-webapp: Renamed locatebuild
  changelog-webapp: Login redirect on completion
  changelog: Test error codes
  findbuild: Handle early Chromium builds
  changelog-webapp: Added signout
  changelog: Added caveat info
  changelog: Added error interface and utils
  changelog: Use ChangelogError errors
  changelog-webapp: Link creation using new changelog package
  changelog: Support multiple repos with same name
diff --git a/go.mod b/go.mod
index b915599..a5960a0 100644
--- a/go.mod
+++ b/go.mod
@@ -19,7 +19,6 @@
 	go.chromium.org/luci v0.0.0-20200722211809-bab0c30be68b
 	golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
 	google.golang.org/api v0.28.0
-	google.golang.org/grpc v1.29.1
 	google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790
 	google.golang.org/grpc v1.29.1
 	google.golang.org/protobuf v1.25.0
diff --git a/src/cmd/changelog-webapp/controllers/authHandlers.go b/src/cmd/changelog-webapp/controllers/authHandlers.go
index aa159ee..2a4a983 100644
--- a/src/cmd/changelog-webapp/controllers/authHandlers.go
+++ b/src/cmd/changelog-webapp/controllers/authHandlers.go
@@ -16,7 +16,6 @@
 
 import (
 	"context"
-	"errors"
 	"fmt"
 	"math/rand"
 	"net/http"
@@ -35,7 +34,6 @@
 	// Session variables
 	sessionName      = "changelog"
 	sessionKeyLength = 32
-	sessionAge       = 84600
 
 	// Oauth state generation variables
 	oauthStateCharset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890"
@@ -54,27 +52,22 @@
 var clientSecretName = os.Getenv("COS_CHANGELOG_CLIENT_SECRET_NAME")
 var sessionSecretName = os.Getenv("COS_CHANGELOG_SESSION_SECRET_NAME")
 
-// ErrorSessionRetrieval indicates that a request has no session, or the
-// session was malformed.
-var ErrorSessionRetrieval = errors.New("No session found")
-
 func init() {
 	var err error
 	client, err := secretmanager.NewClient(context.Background())
 	if err != nil {
-		log.Fatalf("Failed to setup client: %v", err)
+		log.Fatalf("failed to setup client: %v", err)
 	}
 	config.ClientSecret, err = getSecret(client, clientSecretName)
 	if err != nil {
-		log.Fatalf("Failed to retrieve secret: %s\n%v", clientSecretName, err)
+		log.Fatalf("failed to retrieve secret: %s\n%v", clientSecretName, err)
 	}
 
 	sessionSecret, err := getSecret(client, sessionSecretName)
 	if err != nil {
-		log.Fatalf("Failed to retrieve secret :%s\n%v", sessionSecretName, err)
+		log.Fatalf("failed to retrieve secret :%s\n%v", sessionSecretName, err)
 	}
 	store = sessions.NewCookieStore([]byte(sessionSecret))
-	store.MaxAge(sessionAge)
 }
 
 // Retrieve secrets stored in Gcloud Secret Manager
@@ -84,7 +77,7 @@
 	}
 	result, err := client.AccessSecretVersion(context.Background(), accessRequest)
 	if err != nil {
-		return "", fmt.Errorf("Failed to access secret at %s: %v", accessRequest.Name, err)
+		return "", fmt.Errorf("failed to access secret at %s: %v", accessRequest.Name, err)
 	}
 	return string(result.Payload.Data), nil
 }
@@ -102,27 +95,60 @@
 	return state[oauthStateLength:]
 }
 
-// HTTPClient creates an authorized HTTP Client using stored token credentials.
-// Returns error if no session or a malformed session is detected.
-// Otherwise returns an HTTP Client with the stored Oauth access token.
-// If the access token is expired, automatically refresh the token
-func HTTPClient(w http.ResponseWriter, r *http.Request, returnURL string) (*http.Client, error) {
+// tokenExpired indicates whether the Oauth token associated with a request is expired
+func tokenExpired(r *http.Request) bool {
 	var parsedExpiry time.Time
+	session, _ := store.Get(r, sessionName)
+	parsedExpiry, err := time.Parse(time.RFC3339, session.Values["expiry"].(string))
+	return err != nil || parsedExpiry.Before(time.Now())
+}
+
+// GetLoginURL returns a login URL to redirect the user to
+func GetLoginURL(redirect string, auto bool) string {
+	return fmt.Sprintf("/login/?redirect=%s&auto=%v", redirect, auto)
+}
+
+// SignedIn returns a bool indicating if the current request is signed in
+func SignedIn(r *http.Request) bool {
 	session, err := store.Get(r, sessionName)
 	if err != nil || session.IsNew {
-		return nil, ErrorSessionRetrieval
+		return false
 	}
 	for _, key := range []string{"accessToken", "refreshToken", "tokenType", "expiry"} {
 		if val, ok := session.Values[key]; !ok || val == nil {
-			return nil, ErrorSessionRetrieval
+			return false
 		}
 	}
-	if parsedExpiry, err = time.Parse(time.RFC3339, session.Values["expiry"].(string)); err != nil {
-		return nil, ErrorSessionRetrieval
+	return true
+}
+
+// RequireToken will check if the user has a valid, unexpired Oauth token.
+// If not, it will initiate the Oauth flow.
+// Returns a bool indicating if the user was redirected
+func RequireToken(w http.ResponseWriter, r *http.Request, activePage string) bool {
+	if !SignedIn(r) {
+		err := promptLoginTemplate.Execute(w, &statusPage{ActivePage: activePage})
+		if err != nil {
+			log.Errorf("error executing promptLogin template: %v", err)
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+		}
+		return true
 	}
-	if parsedExpiry.Before(time.Now()) {
-		log.Debug("HTTPClient: Token expired, calling Oauth flow")
-		HandleLogin(w, r, returnURL)
+	// If token is expired, auto refresh instead of prompting sign in
+	if tokenExpired(r) {
+		loginURL := GetLoginURL(activePage, true)
+		http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect)
+		return true
+	}
+	return false
+}
+
+// HTTPClient creates an authorized HTTP Client using stored token credentials.
+func HTTPClient(w http.ResponseWriter, r *http.Request) (*http.Client, error) {
+	session, _ := store.Get(r, sessionName)
+	parsedExpiry, err := time.Parse(time.RFC3339, session.Values["expiry"].(string))
+	if err != nil {
+		return nil, err
 	}
 	token := &oauth2.Token{
 		AccessToken:  session.Values["accessToken"].(string),
@@ -134,21 +160,36 @@
 }
 
 // HandleLogin initiates the Oauth flow.
-func HandleLogin(w http.ResponseWriter, r *http.Request, returnURL string) {
-	state := randomString(oauthStateLength, returnURL)
-	// Ignore store.Get() errors in HandleLogin because an error indicates the
+func HandleLogin(w http.ResponseWriter, r *http.Request) {
+	if err := r.ParseForm(); err != nil {
+		log.Errorf("could not parse request: %v\n", err)
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	autoAuth := r.FormValue("auto") == "true"
+	redirect := r.FormValue("redirect")
+	if redirect == "" {
+		redirect = "/"
+	}
+
+	state := randomString(oauthStateLength, redirect)
+	// Ignore store.Get() errors because an error indicates the
 	// old session could not be deciphered. It returns a new session
 	// regardless.
 	session, _ := store.Get(r, sessionName)
 	session.Values["oauthState"] = state
 	err := session.Save(r, w)
 	if err != nil {
-		log.Errorf("HandleLogin: Error saving key: %v", err)
+		log.Errorf("Error saving key: %v", err)
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-
-	authURL := config.AuthCodeURL(state, oauth2.AccessTypeOffline)
+	var authURL string
+	if autoAuth {
+		authURL = config.AuthCodeURL(state, oauth2.AccessTypeOffline)
+	} else {
+		authURL = config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce)
+	}
 	http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
 }
 
@@ -157,7 +198,7 @@
 // Redirects to URL stored in the callback state on completion.
 func HandleCallback(w http.ResponseWriter, r *http.Request) {
 	if err := r.ParseForm(); err != nil {
-		log.Errorf("Could not parse query: %v\n", err)
+		log.Errorf("could not parse query: %v\n", err)
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		return
 	}
@@ -166,7 +207,7 @@
 
 	session, err := store.Get(r, sessionName)
 	if err != nil {
-		log.Errorf("HandleCallback: Error retrieving session: %v", err)
+		log.Errorf("error retrieving session: %v", err)
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
@@ -182,7 +223,7 @@
 
 	token, err := config.Exchange(context.Background(), authCode)
 	if err != nil {
-		log.Errorf("HandleCallback: Error exchanging token: %v", token)
+		log.Errorf("error exchanging token: %v", token)
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
@@ -193,10 +234,34 @@
 	session.Values["expiry"] = token.Expiry.Format(time.RFC3339)
 	err = session.Save(r, w)
 	if err != nil {
-		log.Errorf("HandleCallback: Error saving session: %v", err)
+		log.Errorf("error saving session: %v", err)
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
+	http.Redirect(w, r, returnURLFromState(sessionState), http.StatusTemporaryRedirect)
+}
 
-	http.Redirect(w, r, returnURLFromState(sessionState), http.StatusPermanentRedirect)
+// HandleSignOut signs out the user by removing token information from the
+// session
+func HandleSignOut(w http.ResponseWriter, r *http.Request) {
+	if err := r.ParseForm(); err != nil {
+		log.Errorf("could not parse request: %v\n", err)
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	redirect := r.FormValue("redirect")
+	if redirect == "" {
+		redirect = "/"
+	}
+	session, _ := store.Get(r, sessionName)
+	session.Values["accessToken"] = nil
+	session.Values["refreshToken"] = nil
+	session.Values["tokenType"] = nil
+	session.Values["expiry"] = nil
+	err := session.Save(r, w)
+	if err != nil {
+		log.Errorf("error saving session: %v", err)
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+	}
+	http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
 }
diff --git a/src/cmd/changelog-webapp/controllers/pageHandlers.go b/src/cmd/changelog-webapp/controllers/pageHandlers.go
index 87eb7d9..c0faf97 100644
--- a/src/cmd/changelog-webapp/controllers/pageHandlers.go
+++ b/src/cmd/changelog-webapp/controllers/pageHandlers.go
@@ -16,11 +16,9 @@
 
 import (
 	"context"
-	"errors"
 	"fmt"
 	"net/http"
 	"os"
-	"regexp"
 	"strconv"
 	"strings"
 	"text/template"
@@ -28,8 +26,7 @@
 	secretmanager "cloud.google.com/go/secretmanager/apiv1"
 	"cos.googlesource.com/cos/tools/src/pkg/changelog"
 	"cos.googlesource.com/cos/tools/src/pkg/findbuild"
-	"google.golang.org/grpc/codes"
-	"google.golang.org/grpc/status"
+	"cos.googlesource.com/cos/tools/src/pkg/utils"
 
 	log "github.com/sirupsen/logrus"
 )
@@ -54,43 +51,9 @@
 	indexTemplate           *template.Template
 	changelogTemplate       *template.Template
 	promptLoginTemplate     *template.Template
-	locateBuildTemplate     *template.Template
+	findBuildTemplate       *template.Template
 	statusForbiddenTemplate *template.Template
 	basicTextTemplate       *template.Template
-
-	grpcCodeToHTTP = map[string]string{
-		codes.Unknown.String():            "500",
-		codes.InvalidArgument.String():    "400",
-		codes.NotFound.String():           "404",
-		codes.PermissionDenied.String():   "403",
-		codes.Unauthenticated.String():    "401",
-		codes.ResourceExhausted.String():  "429",
-		codes.FailedPrecondition.String(): "400",
-		codes.OutOfRange.String():         "400",
-		codes.Internal.String():           "500",
-		codes.Unavailable.String():        "503",
-		codes.DataLoss.String():           "500",
-	}
-	httpCodeToHeader = map[string]string{
-		"400": "400 Bad Request",
-		"401": "401 Unauthorized",
-		"403": "403 Forbidden",
-		"404": "404 Not Found",
-		"429": "429 Too Many Requests",
-		"500": "500 Internal Server Error",
-		"503": "503 Service Unavailable",
-	}
-	httpCodeToDesc = map[string]string{
-		"400": "The request could not be understood.",
-		"401": "You are currently unauthenticated. Please login to access this resource.",
-		"403": "This account does not have access to internal repositories. Please retry with an authorized account, or select the external button to query from publically accessible builds.",
-		"404": "The requested resource was not found. Please verify that you are using a valid input.",
-		"429": "Our servers are currently experiencing heavy load. Please retry in a couple minutes.",
-		"500": "Something went wrong on our end. Please try again later.",
-		"503": "This service is temporarily offline. Please try again later.",
-	}
-	gitiles403Desc  = "unexpected HTTP 403 from Gitiles"
-	gerritErrCodeRe = regexp.MustCompile("status code\\s*(\\d+)")
 )
 
 func init() {
@@ -124,7 +87,7 @@
 	staticBasePath = os.Getenv("STATIC_BASE_PATH")
 	indexTemplate = template.Must(template.ParseFiles(staticBasePath + "templates/index.html"))
 	changelogTemplate = template.Must(template.ParseFiles(staticBasePath + "templates/changelog.html"))
-	locateBuildTemplate = template.Must(template.ParseFiles(staticBasePath + "templates/locateBuild.html"))
+	findBuildTemplate = template.Must(template.ParseFiles(staticBasePath + "templates/findBuild.html"))
 	promptLoginTemplate = template.Must(template.ParseFiles(staticBasePath + "templates/promptLogin.html"))
 	basicTextTemplate = template.Must(template.ParseFiles(staticBasePath + "templates/error.html"))
 }
@@ -146,22 +109,24 @@
 	Internal   bool
 }
 
-type locateBuildPage struct {
-	CL            string
-	CLNum         string
-	BuildNum      string
-	GerritLink    string
-	Internal      bool
+type findBuildPage struct {
+	CL         string
+	CLNum      string
+	BuildNum   string
+	GerritLink string
+	Internal   bool
 }
 
 type statusPage struct {
 	ActivePage string
+	SignedIn   bool
 }
 
 type basicTextPage struct {
 	Header     string
 	Body       string
 	ActivePage string
+	SignedIn   bool
 }
 
 type repoTable struct {
@@ -198,29 +163,24 @@
 func getIntVerifiedEnv(envName string) string {
 	output := os.Getenv(envName)
 	if _, err := strconv.Atoi(output); err != nil {
-		log.Errorf("getEnvAsInt: Failed to parse env variable %s with value %s: %v",
+		log.Errorf("failed to parse env variable %s with value %s: %v",
 			envName, os.Getenv(output), err)
 	}
 	return output
 }
 
-func unwrappedError(err error) error {
-	innerErr := err
-	for errors.Unwrap(innerErr) != nil {
-		innerErr = errors.Unwrap(innerErr)
-	}
-	return innerErr
-}
-
 func gobCommitLink(instance, repo, SHA string) string {
 	return fmt.Sprintf("https://%s/%s/+/%s", instance, repo, SHA)
 }
 
-func gobDiffLink(instance, repo, sourceSHA, targetSHA string) string {
+func gobDiffLink(instance, repo, sourceSHA, targetSHA string, diffLink bool) string {
+	if !diffLink {
+		return fmt.Sprintf("https://%s/%s/+log/%s?n=10000", instance, repo, targetSHA)
+	}
 	return fmt.Sprintf("https://%s/%s/+log/%s..%s?n=10000", instance, repo, sourceSHA, targetSHA)
 }
 
-func createRepoTableEntry(instance string, repo string, commit *changelog.Commit, isAddition bool) *repoTableEntry {
+func createRepoTableEntry(instance, repo string, commit *changelog.Commit, isAddition bool) *repoTableEntry {
 	entry := new(repoTableEntry)
 	entry.IsAddition = isAddition
 	entry.SHA = &shaAttr{Name: commit.SHA[:8], URL: gobCommitLink(instance, repo, commit.SHA)}
@@ -242,45 +202,47 @@
 
 func createChangelogPage(data changelogData) *changelogPage {
 	page := &changelogPage{Source: data.Source, Target: data.Target, QuerySize: envQuerySize, Internal: data.Internal}
-	for repoName, repoLog := range data.Additions {
-		table := &repoTable{Name: repoName}
-		for _, commit := range repoLog.Commits {
-			tableEntry := createRepoTableEntry(data.Instance, repoName, commit, true)
+	for repoPath, addLog := range data.Additions {
+		diffLink := false
+		table := &repoTable{Name: repoPath}
+		for _, commit := range addLog.Commits {
+			tableEntry := createRepoTableEntry(data.Instance, addLog.Repo, commit, true)
 			table.Additions = append(table.Additions, tableEntry)
 		}
-		if _, ok := data.Removals[repoName]; ok {
-			for _, commit := range data.Removals[repoName].Commits {
-				tableEntry := createRepoTableEntry(data.Instance, repoName, commit, false)
+		if rmLog, ok := data.Removals[repoPath]; ok {
+			for _, commit := range data.Removals[repoPath].Commits {
+				tableEntry := createRepoTableEntry(data.Instance, rmLog.Repo, commit, false)
 				table.Removals = append(table.Removals, tableEntry)
 			}
-			if data.Removals[repoName].HasMoreCommits {
-				table.RemovalsLink = gobDiffLink(data.Instance, repoName, repoLog.TargetSHA, repoLog.SourceSHA)
+			if data.Removals[repoPath].HasMoreCommits {
+				diffLink = addLog.Repo == rmLog.Repo
+				table.RemovalsLink = gobDiffLink(data.Instance, rmLog.Repo, addLog.TargetSHA, rmLog.TargetSHA, diffLink)
 			}
 		}
-		if repoLog.HasMoreCommits {
-			table.AdditionsLink = gobDiffLink(data.Instance, repoName, repoLog.SourceSHA, repoLog.TargetSHA)
+		if addLog.HasMoreCommits {
+			table.AdditionsLink = gobDiffLink(data.Instance, addLog.Repo, addLog.SourceSHA, addLog.TargetSHA, diffLink)
 		}
 		page.RepoTables = append(page.RepoTables, table)
 	}
 	// Add remaining repos that had removals but no additions
-	for repoName, repoLog := range data.Removals {
-		if _, ok := data.Additions[repoName]; ok {
+	for repoPath, repoLog := range data.Removals {
+		if _, ok := data.Additions[repoPath]; ok {
 			continue
 		}
-		table := &repoTable{Name: repoName}
+		table := &repoTable{Name: repoPath}
 		for _, commit := range repoLog.Commits {
-			tableEntry := createRepoTableEntry(data.Instance, repoName, commit, false)
+			tableEntry := createRepoTableEntry(data.Instance, repoLog.Repo, commit, false)
 			table.Removals = append(table.Removals, tableEntry)
 		}
 		page.RepoTables = append(page.RepoTables, table)
 		if repoLog.HasMoreCommits {
-			table.RemovalsLink = gobDiffLink(data.Instance, repoName, repoLog.TargetSHA, repoLog.SourceSHA)
+			table.RemovalsLink = gobDiffLink(data.Instance, repoLog.Repo, repoLog.SourceSHA, repoLog.TargetSHA, false)
 		}
 	}
 	return page
 }
 
-func findBuildWithFallback(httpClient *http.Client, gerrit, fallbackGerrit, gob, repo, cl string, internal bool) (*findbuild.BuildResponse, bool, error) {
+func findBuildWithFallback(httpClient *http.Client, gerrit, fallbackGerrit, gob, repo, cl string, internal bool) (*findbuild.BuildResponse, bool, utils.ChangelogError) {
 	didFallback := false
 	request := &findbuild.BuildRequest{
 		HTTPClient:   httpClient,
@@ -291,8 +253,7 @@
 		CL:           cl,
 	}
 	buildData, err := findbuild.FindBuild(request)
-	innerErr := unwrappedError(err)
-	if innerErr == findbuild.ErrorCLNotFound {
+	if err != nil && err.HTTPCode() == "404" {
 		log.Debugf("Cl %s not found in Gerrit instance, using fallback", cl)
 		fallbackRequest := &findbuild.BuildRequest{
 			HTTPClient:   httpClient,
@@ -308,88 +269,40 @@
 	return buildData, didFallback, err
 }
 
-// parseGRPCError retrieves the header and description to display for a gRPC error
-func parseGRPCError(inputErr error) (string, string, error) {
-	rpcStatus, ok := status.FromError(inputErr)
-	if !ok {
-		return "", "", fmt.Errorf("parseGRPCError: Error %v is not a gRPC error", inputErr)
-	}
-	code, text := rpcStatus.Code(), rpcStatus.Message()
-	// RPC status code misclassifies 403 error as 500 error for Gitiles requests
-	if text == gitiles403Desc {
-		code = codes.PermissionDenied
-	}
-	if httpCode, ok := grpcCodeToHTTP[code.String()]; ok {
-		if _, ok := httpCodeToHeader[httpCode]; !ok {
-			log.Errorf("parseGRPCError: No error header mapping found for HTTP code %s", httpCode)
-			return "", "", fmt.Errorf("parseGRPCError: No error header mapping found for HTTP code %s", httpCode)
-		}
-		if _, ok := httpCodeToDesc[httpCode]; !ok {
-			log.Errorf("parseGRPCError: No error description mapping found for HTTP code %s", httpCode)
-			return "", "", fmt.Errorf("parseGRPCError: No error description mapping found for HTTP code %s", httpCode)
-		}
-		return httpCodeToHeader[httpCode], httpCodeToDesc[httpCode], nil
-	}
-	return "", "", fmt.Errorf("parseGRPCError: gRPC error code %s not supported", code.String())
-}
-
-// parseGitilesError retrieves the header and description to display for a Gerrit error
-func parseGerritError(inputErr error) (string, string, error) {
-	matches := gerritErrCodeRe.FindStringSubmatch(inputErr.Error())
-	if len(matches) != 2 {
-		return "", "", fmt.Errorf("parseGerritError: error %v is not a Gerrit error", inputErr)
-	}
-	httpCode := matches[1]
-	if _, ok := httpCodeToHeader[httpCode]; !ok {
-		log.Errorf("parseGerritError: No error header mapping found for HTTP code %s", httpCode)
-		return "", "", fmt.Errorf("parseGerritError: No error header mapping found for HTTP code %s", httpCode)
-	}
-	if _, ok := httpCodeToDesc[httpCode]; !ok {
-		log.Errorf("parseGerritError: No error description mapping found for HTTP code %s", httpCode)
-		return "", "", fmt.Errorf("parseGerritError: No error description mapping found for HTTP code %s", httpCode)
-	}
-	return httpCodeToHeader[httpCode], httpCodeToDesc[httpCode], nil
-}
-
 // handleError creates the error page for a given error
-func handleError(w http.ResponseWriter, inputErr error, currPage string) {
-	innerErr := unwrappedError(inputErr)
-	header := "An error occurred while handling this request"
-	body := innerErr.Error()
-	if tmpHeader, tmpBody, err := parseGRPCError(innerErr); err == nil {
-		header = tmpHeader
-		body = tmpBody
-	} else if tmpHeader, tmpBody, err := parseGerritError(innerErr); err == nil {
-		header = tmpHeader
-		body = tmpBody
-	}
-	basicTextTemplate.Execute(w, &basicTextPage{
-		Header:     header,
-		Body:       body,
+func handleError(w http.ResponseWriter, r *http.Request, displayErr utils.ChangelogError, currPage string) {
+	err := basicTextTemplate.Execute(w, &basicTextPage{
+		Header:     displayErr.HTTPStatus(),
+		Body:       displayErr.Error(),
 		ActivePage: currPage,
+		SignedIn:   SignedIn(r),
 	})
+	if err != nil {
+		log.Error(err)
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+	}
 }
 
 // HandleIndex serves the home page
 func HandleIndex(w http.ResponseWriter, r *http.Request) {
-	indexTemplate.Execute(w, nil)
+	err := indexTemplate.Execute(w, &statusPage{SignedIn: SignedIn(r)})
+	if err != nil {
+		log.Error(err)
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+	}
 }
 
 // HandleChangelog serves the changelog page
 func HandleChangelog(w http.ResponseWriter, r *http.Request) {
-	httpClient, err := HTTPClient(w, r, "/changelog/")
-	if err != nil {
-		log.Debug(err)
-		err = promptLoginTemplate.Execute(w, &statusPage{ActivePage: "changelog"})
-		if err != nil {
-			log.Errorf("HandleChangelog: error executing promptLogin template: %v", err)
-		}
+	if RequireToken(w, r, "/changelog/") {
 		return
 	}
+	var err error
 	if err := r.ParseForm(); err != nil {
 		err = changelogTemplate.Execute(w, &changelogPage{QuerySize: envQuerySize})
 		if err != nil {
-			log.Errorf("HandleChangelog: error executing locatebuild template: %v", err)
+			log.Errorf("error executing findbuild template: %v", err)
+			http.Error(w, err.Error(), http.StatusInternalServerError)
 		}
 		return
 	}
@@ -399,7 +312,8 @@
 	if source == "" || target == "" {
 		err = changelogTemplate.Execute(w, &changelogPage{QuerySize: envQuerySize, Internal: true})
 		if err != nil {
-			log.Errorf("HandleChangelog: error executing locatebuild template: %v", err)
+			log.Errorf("error executing findbuild template: %v", err)
+			http.Error(w, err.Error(), http.StatusInternalServerError)
 		}
 		return
 	}
@@ -411,11 +325,17 @@
 	if r.FormValue("internal") == "true" {
 		internal, instance, manifestRepo = true, internalGoBInstance, internalManifestRepo
 	}
-	added, removed, err := changelog.Changelog(httpClient, source, target, instance, manifestRepo, querySize)
+	httpClient, err := HTTPClient(w, r)
 	if err != nil {
-		log.Errorf("HandleChangelog: error retrieving changelog between builds %s and %s on GoB instance: %s with manifest repository: %s\n%v\n",
-			source, target, externalGoBInstance, externalManifestRepo, err)
-		handleError(w, err, "/changelog/")
+		loginURL := GetLoginURL("/changelog/", false)
+		http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect)
+		return
+	}
+	added, removed, utilErr := changelog.Changelog(httpClient, source, target, instance, manifestRepo, querySize)
+	if utilErr != nil {
+		log.Errorf("error retrieving changelog between builds %s and %s on GoB instance: %s with manifest repository: %s\n%v\n",
+			source, target, externalGoBInstance, externalManifestRepo, utilErr)
+		handleError(w, r, utilErr, "/changelog/")
 		return
 	}
 	page := createChangelogPage(changelogData{
@@ -428,35 +348,32 @@
 	})
 	err = changelogTemplate.Execute(w, page)
 	if err != nil {
-		log.Errorf("HandleChangelog: error executing changelog template: %v", err)
+		log.Errorf("error executing changelog template: %v", err)
+		http.Error(w, err.Error(), http.StatusInternalServerError)
 	}
 }
 
-// HandleLocateBuild serves the Locate CL page
-func HandleLocateBuild(w http.ResponseWriter, r *http.Request) {
-	httpClient, err := HTTPClient(w, r, "/locatebuild/")
-	// Require login to access if no session found
-	if err != nil {
-		log.Debug(err)
-		err = promptLoginTemplate.Execute(w, &statusPage{ActivePage: "locatebuild"})
-		if err != nil {
-			log.Errorf("HandleLocateBuild: error executing promptLogin template: %v", err)
-		}
+// HandleFindBuild serves the Locate CL page
+func HandleFindBuild(w http.ResponseWriter, r *http.Request) {
+	if RequireToken(w, r, "/findbuild/") {
 		return
 	}
-	if err := r.ParseForm(); err != nil {
-		err = locateBuildTemplate.Execute(w, &locateBuildPage{Internal: true})
+	var err error
+	if err = r.ParseForm(); err != nil {
+		err = findBuildTemplate.Execute(w, &findBuildPage{Internal: true})
 		if err != nil {
-			log.Errorf("HandleLocateBuild: error executing locatebuild template: %v", err)
+			log.Errorf("error executing findbuild template: %v", err)
+			http.Error(w, err.Error(), http.StatusInternalServerError)
 		}
 		return
 	}
 	cl := r.FormValue("cl")
 	// If no CL value specified in request, display empty CL form
 	if cl == "" {
-		err = locateBuildTemplate.Execute(w, &locateBuildPage{Internal: true})
+		err = findBuildTemplate.Execute(w, &findBuildPage{Internal: true})
 		if err != nil {
-			log.Errorf("HandleLocateBuild: error executing locatebuild template: %v", err)
+			log.Errorf("error executing findbuild template: %v", err)
+			http.Error(w, err.Error(), http.StatusInternalServerError)
 		}
 		return
 	}
@@ -464,10 +381,16 @@
 	if r.FormValue("internal") == "true" {
 		internal, gerrit, fallbackGerrit, gob, repo = true, internalGerritInstance, internalFallbackGerritInstance, internalGoBInstance, internalManifestRepo
 	}
-	buildData, didFallback, err := findBuildWithFallback(httpClient, gerrit, fallbackGerrit, gob, repo, cl, internal)
+	httpClient, err := HTTPClient(w, r)
 	if err != nil {
-		log.Errorf("Handlelocatebuild: error retrieving build for CL %s with internal set to %t\n%v", cl, internal, err)
-		handleError(w, err, "/locatebuild/")
+		loginURL := GetLoginURL("/findbuild/", false)
+		http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect)
+		return
+	}
+	buildData, didFallback, utilErr := findBuildWithFallback(httpClient, gerrit, fallbackGerrit, gob, repo, cl, internal)
+	if utilErr != nil {
+		log.Errorf("error retrieving build for CL %s with internal set to %t\n%v", cl, internal, utilErr)
+		handleError(w, r, utilErr, "/findbuild/")
 		return
 	}
 	var gerritLink string
@@ -476,15 +399,16 @@
 	} else {
 		gerritLink = gerrit + "/q/" + buildData.CLNum
 	}
-	page := &locateBuildPage{
+	page := &findBuildPage{
 		CL:         cl,
 		CLNum:      buildData.CLNum,
 		BuildNum:   buildData.BuildNum,
 		Internal:   internal,
 		GerritLink: gerritLink,
 	}
-	err = locateBuildTemplate.Execute(w, page)
+	err = findBuildTemplate.Execute(w, page)
 	if err != nil {
-		log.Errorf("HandleLocateBuild: error executing locatebuild template: %v", err)
+		log.Errorf("error executing findbuild template: %v", err)
+		http.Error(w, err.Error(), http.StatusInternalServerError)
 	}
 }
diff --git a/src/cmd/changelog-webapp/main.go b/src/cmd/changelog-webapp/main.go
index 3813591..5d2caa7 100644
--- a/src/cmd/changelog-webapp/main.go
+++ b/src/cmd/changelog-webapp/main.go
@@ -39,11 +39,10 @@
 	http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticBasePath))))
 	http.HandleFunc("/", controllers.HandleIndex)
 	http.HandleFunc("/changelog/", controllers.HandleChangelog)
-	http.HandleFunc("/locatebuild/", controllers.HandleLocateBuild)
-	http.HandleFunc("/login/", func(w http.ResponseWriter, r *http.Request) {
-		controllers.HandleLogin(w, r, "/")
-	})
+	http.HandleFunc("/findbuild/", controllers.HandleFindBuild)
+	http.HandleFunc("/login/", controllers.HandleLogin)
 	http.HandleFunc("/oauth2callback/", controllers.HandleCallback)
+	http.HandleFunc("/signout/", controllers.HandleSignOut)
 
 	if port == "" {
 		port = "8081"
diff --git a/src/cmd/changelog-webapp/static/css/base.css b/src/cmd/changelog-webapp/static/css/base.css
index 7d763c7..03a1865 100644
--- a/src/cmd/changelog-webapp/static/css/base.css
+++ b/src/cmd/changelog-webapp/static/css/base.css
@@ -44,7 +44,6 @@
   background-color: #4285f4;
   display: flex;
   height: 50px;
-  padding: 0px 25px;
   width: 100%;
 }
 
@@ -52,6 +51,17 @@
   color: #f8f8f8;
   font-size: 24;
   font-weight: 350;
+  padding-left: 25px;
+}
+
+a.login,
+a.signout {
+  color: #f8f8f8;
+  float: right;
+  font-size: 16;
+  font-weight: 16px;
+  margin-left: auto;
+  padding-right: 25px;
 }
 
 .sidenav {
@@ -62,7 +72,7 @@
   padding-top: 30px;
   position: fixed;
   top: 0;
-  width: 150px;
+  width: 155px;
   z-index: 1;
 }
 
@@ -86,7 +96,7 @@
 }
 
 .main {
-  margin: 30px 25px 0px 160px;
+  margin: 30px 25px 0px 155px;
 }
 
 .text-content {
diff --git a/src/cmd/changelog-webapp/static/css/changelog.css b/src/cmd/changelog-webapp/static/css/changelog.css
index 5098deb..1301694 100644
--- a/src/cmd/changelog-webapp/static/css/changelog.css
+++ b/src/cmd/changelog-webapp/static/css/changelog.css
@@ -10,10 +10,16 @@
     padding: 2px;
 }
 
+.feature-info {
+    font-size: 14;
+    margin: 6px 0px;
+    width: 650px;
+}
+
 .changelog-form .text input {
     font-size: 14px;
     height: 26px;
-    margin-bottom: 8px;
+    margin: 8px 2px;
     width: 135px;
 }
 
@@ -23,10 +29,7 @@
 
 .changelog-form .text .submit {
     width: 59px;
-}
-
-.changelog-form .text label {
-    margin: 4px;
+    margin: 0px;
 }
 
 .changelog-form .radio .external {
diff --git a/src/cmd/changelog-webapp/static/templates/changelog.html b/src/cmd/changelog-webapp/static/templates/changelog.html
index e5a3c3d..92c2fc0 100644
--- a/src/cmd/changelog-webapp/static/templates/changelog.html
+++ b/src/cmd/changelog-webapp/static/templates/changelog.html
@@ -7,15 +7,17 @@
 <body>
   <div class="navbar">
     <p class="navbar-title">Container Optimized OS</p>
+    <a class="signout" href="/signout/?redirect=/changelog/">Sign Out</a>
   </div>
   <div class="sidenav">
     <a href="/">Home</a>
     <a class="active" href="/changelog/">Changelog</a>
-    <a href="/locatebuild/">Locate Build</a>
-    <a href="/login/">Login</a>
+    <a href="/findbuild/">Find Build</a>
   </div>
   <div class="main">
     <h1>Search Changelog</h1>
+    <p class="feature-info">Retrieve a list of commits between two Container Optimized OS build numbers.</p>
+    <p class="feature-info">Note: Build numbers generated using ChromiumOS infrastructure are not supported.</p>
     <form class="changelog-form" action="/changelog">
       <div class="text">
         <label>From </label>
diff --git a/src/cmd/changelog-webapp/static/templates/error.html b/src/cmd/changelog-webapp/static/templates/error.html
index 0264ba4..e880f95 100644
--- a/src/cmd/changelog-webapp/static/templates/error.html
+++ b/src/cmd/changelog-webapp/static/templates/error.html
@@ -6,6 +6,11 @@
 <body>
   <div class="navbar">
     <p class="navbar-title">Container Optimized OS</p>
+    {{if .SignedIn}}
+      <a class="signout" href="/signout/?redirect={{.ActivePage}}">Sign Out</a>
+    {{else}}
+      <a class="login" href="/login/?redirect={{.ActivePage}}">Login</a>
+    {{end}}
   </div>
   <div class="sidenav">
     {{if (eq .ActivePage "home")}}
@@ -18,17 +23,18 @@
     {{else}}
       <a href="/changelog/">Changelog</a>
     {{end}}
-    {{if (eq .ActivePage "/locatebuild/")}}
-      <a class="active" href="/locatebuild/">Locate Build</a>
+    {{if (eq .ActivePage "/findbuild/")}}
+      <a class="active" href="/findbuild/">Find Build</a>
     {{else}}
-      <a href="/locatebuild/">Locate Build</a>
+      <a href="/findbuild/">Find Build</a>
     {{end}}
-    <a href="/login/">Login</a>
   </div>
   <div class="main">
-    <h1>{{ .Header }}</h1>
-    <p>{{ .Body }}</p>
-    <a href={{.ActivePage}}>Go back</a>
+    <div class="text-content">
+      <h1>{{ .Header }}</h1>
+      <p>{{ .Body }}</p>
+      <a href={{.ActivePage}}>Go back</a>
+    </div>
   </div>
 </body>
 
diff --git a/src/cmd/changelog-webapp/static/templates/locateBuild.html b/src/cmd/changelog-webapp/static/templates/findBuild.html
similarity index 76%
rename from src/cmd/changelog-webapp/static/templates/locateBuild.html
rename to src/cmd/changelog-webapp/static/templates/findBuild.html
index ca09a73..b48cb19 100644
--- a/src/cmd/changelog-webapp/static/templates/locateBuild.html
+++ b/src/cmd/changelog-webapp/static/templates/findBuild.html
@@ -7,16 +7,18 @@
 <body>
   <div class="navbar">
     <p class="navbar-title">Container Optimized OS</p>
+    <a class="signout" href="/signout/?redirect=/findbuild/">Sign Out</a>
   </div>
   <div class="sidenav">
     <a href="/">Home</a>
     <a href="/changelog/">Changelog</a>
-    <a class="active" href="/locatebuild/">Locate Build</a>
-    <a href="/login/">Login</a>
+    <a class="active" href="/findbuild/">Find Build</a>
   </div>
   <div class="main">
-    <h1>Locate Build with CL</h1>
-    <form class="changelog-form" action="/locatebuild">
+    <h1>Find Build with CL</h1>
+    <p class="feature-info">Find the first build containing a requested CL.</p>
+    <p class="feature-info">Note: It is possible that a COS build was not created using the build number returned. However, any COS build with build number greater than the returned build number should have the desired change present.</p>
+    <form class="changelog-form" action="/findbuild">
       <div class="text">
         {{if (ne .CL "")}}
           <input type="text" class="cl-input" name="cl" placeholder="CL-Number or Commit-SHA" value={{.CL}} autocomplete="off" required>
diff --git a/src/cmd/changelog-webapp/static/templates/index.html b/src/cmd/changelog-webapp/static/templates/index.html
index 350a422..1aaf48e 100644
--- a/src/cmd/changelog-webapp/static/templates/index.html
+++ b/src/cmd/changelog-webapp/static/templates/index.html
@@ -6,12 +6,16 @@
 <body>
   <div class="navbar">
     <p class="navbar-title">Container Optimized OS</p>
+    {{if .SignedIn}}
+      <a class="signout" href="/signout/">Sign Out</a>
+    {{else}}
+      <a class="login" href="/login/">Login</a>
+    {{end}}
   </div>
   <div class="sidenav">
     <a class="active" href="/">Home</a>
     <a href="/changelog/">Changelog</a>
-    <a href="/locatebuild/">Locate Build</a>
-    <a href="/login/">Login</a>
+    <a href="/findbuild/">Find Build</a>
   </div>
   <div class="main">
     <div class="text-content">
diff --git a/src/cmd/changelog-webapp/static/templates/promptLogin.html b/src/cmd/changelog-webapp/static/templates/promptLogin.html
index 9ca8fb9..cab2f47 100644
--- a/src/cmd/changelog-webapp/static/templates/promptLogin.html
+++ b/src/cmd/changelog-webapp/static/templates/promptLogin.html
@@ -6,6 +6,7 @@
 <body>
   <div class="navbar">
     <p class="navbar-title">Container Optimized OS</p>
+    <a class="login" href="/login/?redirect={{.ActivePage}}">Login</a>
   </div>
   <div class="sidenav">
     <a href="/">Home</a>
@@ -14,18 +15,18 @@
     {{else}}
       <a href="/changelog/">Changelog</a>
     {{end}}
-    {{if (eq .ActivePage "/locatebuild/")}}
-      <a class="active" href="/locatebuild/">Locate Build</a>
+    {{if (eq .ActivePage "/findbuild/")}}
+      <a class="active" href="/findbuild/">Find Build</a>
     {{else}}
-      <a href="/locatebuild/">Locate Build</a>
+      <a href="/findbuild/">Find Build</a>
     {{end}}
-    <a href="/login/">Login</a>
   </div>
   <div class="main">
     <div class="text-content">
       <h1>Please sign in to use this feature</h1>
       <p>This application requires OAuth authentication to communicate with Google Git repositories. Your account data
-        will not be used for any other purposes. To continue, please sign in by clicking <a href="/login/">here</a>.
+        will not be used for any other purposes. To continue, please sign in by clicking 
+        <a href="/login/?redirect={{.ActivePage}}">here</a>.
       </p>
     </div>
   </div>
diff --git a/src/cmd/changelog/README.md b/src/cmd/changelog/README.md
index efb241d..3730617 100644
--- a/src/cmd/changelog/README.md
+++ b/src/cmd/changelog/README.md
@@ -45,4 +45,7 @@
 
 ## FindCL output
 
-Prints the first build number that includes the input CL.
\ No newline at end of file
+Prints the first build number that includes the input CL.
+
+## Notes
+* Changelog only supports querying from build numbers from [COS GoB](cos.googlesource.com). It does not support build numbers generated using [ChromiumOS GoB](https://chromium.googlesource.com/).
\ No newline at end of file
diff --git a/src/cmd/changelog/main.go b/src/cmd/changelog/main.go
index 00b0d79..6332e19 100755
--- a/src/cmd/changelog/main.go
+++ b/src/cmd/changelog/main.go
@@ -51,14 +51,6 @@
 	fallbackRepoPrefix   = "mirrors/cros/"
 )
 
-func unwrappedError(err error) error {
-	innerErr := err
-	for errors.Unwrap(innerErr) != nil {
-		innerErr = errors.Unwrap(innerErr)
-	}
-	return innerErr
-}
-
 func getHTTPClient() (*http.Client, error) {
 	log.Debug("Creating HTTP client")
 	creds, err := google.FindDefaultCredentials(context.Background(), gerrit.OAuthScope)
@@ -107,7 +99,7 @@
 func getBuildForCL(gerrit, fallback, gob, manifestRepo, fallbackPrefix, targetCL string) error {
 	httpClient, err := getHTTPClient()
 	if err != nil {
-		return fmt.Errorf("Error creating http client: %v", err)
+		return fmt.Errorf("error creating http client: %v", err)
 	}
 	req := &findbuild.BuildRequest{
 		HTTPClient:   httpClient,
@@ -117,8 +109,8 @@
 		RepoPrefix:   "",
 		CL:           targetCL,
 	}
-	buildData, err := findbuild.FindBuild(req)
-	if unwrappedError(err) == findbuild.ErrorCLNotFound {
+	buildData, clErr := findbuild.FindBuild(req)
+	if clErr != nil && clErr.HTTPCode() == "404" {
 		log.Debugf("Query failed on Gerrit url %s and Gitiles url %s, retrying with fallback urls", externalGerritURL, externalGoBURL)
 		fallbackReq := &findbuild.BuildRequest{
 			HTTPClient:   httpClient,
@@ -128,10 +120,10 @@
 			RepoPrefix:   fallbackPrefix,
 			CL:           targetCL,
 		}
-		buildData, err = findbuild.FindBuild(fallbackReq)
+		buildData, clErr = findbuild.FindBuild(fallbackReq)
 	}
-	if err != nil {
-		return err
+	if clErr != nil {
+		return clErr
 	}
 	log.Infof("Build: %s", buildData.BuildNum)
 	return nil
@@ -211,7 +203,7 @@
 				target := c.Args().Get(1)
 				return generateChangelog(source, target, gobURL, manifestRepo)
 			default:
-				return fmt.Errorf("Please specify either \"findbuild\" or \"changelog\" mode")
+				return fmt.Errorf("please specify either \"findbuild\" or \"changelog\" mode")
 			}
 		},
 	}
diff --git a/src/cmd/changelog/main_test.py b/src/cmd/changelog/main_test.py
index 71e5546..d881eb7 100644
--- a/src/cmd/changelog/main_test.py
+++ b/src/cmd/changelog/main_test.py
@@ -114,7 +114,7 @@
 
     def test_large_run(self):
         source = "15055.0.0"
-        target = "15000.0.0"
+        target = "15030.0.0"
         instance = "cos.googlesource.com"
         repo = "cos/manifest-snapshots"
         delete_logs(source, target)
@@ -122,7 +122,7 @@
         assert process.returncode == 0
         assert check_file_exists(source, target)
         assert check_file_exists(target, source)
-        assert check_changelog_schema(source, target)
+        assert check_empty_json_file(source, target)
         assert check_changelog_schema(target, source)
 
     def test_with_invalid_source(self):
diff --git a/src/pkg/changelog/changelog.go b/src/pkg/changelog/changelog.go
index 098138a..9db2099 100644
--- a/src/pkg/changelog/changelog.go
+++ b/src/pkg/changelog/changelog.go
@@ -25,13 +25,12 @@
 // URL. A request is sent on a seperate thread for each repository, asking for a list
 // of commits that occurred between the source committish and the target committish.
 // Finally, the resulting git.Commit objects are converted to Commit objects, and
-// consolidated into a mapping of repositoryName -> []*Commit.
+// consolidated into a mapping of repository path -> []*Commit.
 
 package changelog
 
 import (
 	"errors"
-	"fmt"
 	"net/http"
 	"strings"
 
@@ -43,6 +42,8 @@
 )
 
 type repo struct {
+	Repo string
+	Path string
 	// The Git on Borg instance to query from.
 	InstanceURL string
 	// A value that points to the last commit for a build on a given repo.
@@ -56,53 +57,85 @@
 }
 
 type commitsResult struct {
-	RepoURL        string
 	Commits        []*Commit
+	Repo           string
+	Path           string
 	HasMoreCommits bool
-	Err            error
+	Err            utils.ChangelogError
 }
 
 type additionsResult struct {
 	Additions map[string]*RepoLog
-	Err       error
+	Err       utils.ChangelogError
 }
 
-func gerritClient(httpClient *http.Client, remoteURL string) (gitilesProto.GitilesClient, error) {
-	log.Debugf("Creating Gerrit client for remote url %s\n", remoteURL)
+// RepoLog contains a changelist for a particular repository
+type RepoLog struct {
+	Commits        []*Commit
+	Repo           string
+	SourceSHA      string
+	TargetSHA      string
+	HasMoreCommits bool
+}
+
+// Creates a unique identifier for a repo + branch pairing.
+// Path is used instead of dest-branch because some manifest files do not
+// indicate a dest-branch for a project.
+// Path itself is not sufficient to guarantee uniqueness, since some repos
+// share the same path.
+// ex. mirrors/cros/chromiumos/repohooks vs cos/repohooks
+func repoID(name, path string) string {
+	return name + path
+}
+
+// limitPageSize will restrict a request page size to min of pageSize (which grows exponentially)
+// or remaining request size
+func limitPageSize(pageSize, requestedSize int) int {
+	if requestedSize == -1 || pageSize <= requestedSize {
+		return pageSize
+	}
+	return requestedSize
+}
+
+func gitilesClient(httpClient *http.Client, remoteURL string) (gitilesProto.GitilesClient, utils.ChangelogError) {
+	log.Debugf("Creating Gitiles client for remote url %s\n", remoteURL)
 	cl, err := gitilesApi.NewRESTClient(httpClient, remoteURL, true)
 	if err != nil {
-		return nil, errors.New("changelog: Failed to establish client to remote url: " + remoteURL)
+		log.Errorf("gitilesClient: failed to create client for remote url %s", remoteURL)
+		return nil, utils.InternalError
 	}
 	return cl, nil
 }
 
-func createGerritClients(clients map[string]gitilesProto.GitilesClient, httpClient *http.Client, repoMap map[string]*repo) error {
+func createGitilesClients(clients map[string]gitilesProto.GitilesClient, httpClient *http.Client, repoMap map[string]*repo) utils.ChangelogError {
 	log.Debug("Creating additional Gerrit clients for manifest file if not already created")
 	for _, repoData := range repoMap {
 		remoteURL := repoData.InstanceURL
 		if _, ok := clients[remoteURL]; ok {
 			continue
 		}
-		client, err := gerritClient(httpClient, remoteURL)
+		client, err := gitilesClient(httpClient, remoteURL)
 		if err != nil {
-			return fmt.Errorf("createClients: error creating client mapping:\n%w", err)
+			return err
 		}
 		clients[remoteURL] = client
 	}
 	return nil
 }
 
-// repoMap generates a mapping of repo name to instance URL and committish.
+// repoMap generates a mapping of repository ID to instance URL and committish.
 // This eliminates the need to track remote names and allows lookup
 // of source committish when generating changelog.
 func repoMap(manifest string) (map[string]*repo, error) {
 	log.Debug("Mapping repository to instance URL and committish")
 	if manifest == "" {
-		return nil, fmt.Errorf("repoMap: manifest data is empty")
+		log.Error("repoMap: manifest file is empty")
+		return nil, errors.New("manifest file is empty")
 	}
 	doc := etree.NewDocument()
 	if err := doc.ReadFromString(manifest); err != nil {
-		return nil, fmt.Errorf("repoMap: error parsing manifest xml:\n%w", err)
+		log.Debug("repoMap: error parsing manifest xml:\n%w", err)
+		return nil, errors.New("could not parse XML for manifest file associated with build")
 	}
 	root := doc.SelectElement("manifest")
 
@@ -123,7 +156,10 @@
 	}
 	repos := make(map[string]*repo)
 	for _, project := range root.SelectElements("project") {
-		repos[project.SelectAttr("name").Value] = &repo{
+		name, path := project.SelectAttr("name").Value, project.SelectAttr("path").Value
+		repos[repoID(name, path)] = &repo{
+			Repo:        name,
+			Path:        path,
 			InstanceURL: remoteMap[project.SelectAttrValue("remote", "")],
 			Committish:  project.SelectAttr("revision").Value,
 		}
@@ -132,35 +168,41 @@
 }
 
 // mappedManifest retrieves a Manifest file from GoB and unmarshals XML.
-func mappedManifest(client gitilesProto.GitilesClient, repo string, buildNum string) (map[string]*repo, error) {
+// Returns a mapping of repository ID to repository data.
+func mappedManifest(client gitilesProto.GitilesClient, repo string, buildNum string) (map[string]*repo, utils.ChangelogError) {
 	log.Debugf("Retrieving manifest file for build %s\n", buildNum)
 	response, err := utils.DownloadManifest(client, repo, buildNum)
 	if err != nil {
-		return nil, fmt.Errorf("mappedManifest: error downloading manifest file from repo %s:\n%w",
-			repo, err)
+		log.Errorf("mappedManifest: error downloading manifest file from repo %s for build %s:\n%v", repo, buildNum, err)
+		httpCode := utils.GitilesErrCode(err)
+		return nil, utils.FromChangelogError(httpCode, buildNum)
 	}
 	mappedManifest, err := repoMap(response.Contents)
 	if err != nil {
-		return nil, fmt.Errorf("mappedManifest: error parsing manifest contents from repo %s:\n%w",
-			repo, err)
+		log.Errorf("mappedManifest: error retrieving mapped manifest file from repo %s for build %s:\n%v", repo, buildNum, err)
+		httpCode := utils.GitilesErrCode(err)
+		return nil, utils.FromChangelogError(httpCode, buildNum)
 	}
 	return mappedManifest, nil
 }
 
-// commits retrieves a parsed list of commits between an ancestor and a committish
-func commits(client gitilesProto.GitilesClient, repo string, committish string, ancestor string, querySize int, outputChan chan commitsResult) {
+// commits get all commits that occur between committish and ancestor for a specific repo.
+func commits(client gitilesProto.GitilesClient, path string, repo string, committish string, ancestor string, querySize int, outputChan chan commitsResult) {
 	log.Debugf("Fetching changelog for repo: %s on committish %s\n", repo, committish)
 	commits, hasMoreCommits, err := utils.Commits(client, repo, committish, ancestor, querySize)
 	if err != nil {
-		outputChan <- commitsResult{Err: err}
+		outputChan <- commitsResult{Err: utils.InternalError}
 	}
 	parsedCommits, err := ParseGitCommitLog(commits)
 	if err != nil {
-		outputChan <- commitsResult{Err: err}
+		log.Errorf("commits: Error parsing Gitiles commits response\n%v", err)
+		outputChan <- commitsResult{Err: utils.InternalError}
+		return
 	}
 	outputChan <- commitsResult{
-		RepoURL:        repo,
 		Commits:        parsedCommits,
+		Path:           path,
+		Repo:           repo,
 		HasMoreCommits: hasMoreCommits,
 	}
 }
@@ -171,15 +213,15 @@
 	log.Debug("Retrieving commit additions")
 	repoCommits := make(map[string]*RepoLog)
 	commitsChan := make(chan commitsResult, len(targetRepos))
-	for repoURL, targetRepoInfo := range targetRepos {
+	for repoID, targetRepoInfo := range targetRepos {
 		cl := clients[targetRepoInfo.InstanceURL]
 		// If the source Manifest file does not contain a target repo,
 		// count every commit since target repo creation as an addition
 		ancestorCommittish := ""
-		if sourceRepoInfo, ok := sourceRepos[repoURL]; ok {
+		if sourceRepoInfo, ok := sourceRepos[repoID]; ok {
 			ancestorCommittish = sourceRepoInfo.Committish
 		}
-		go commits(cl, repoURL, targetRepoInfo.Committish, ancestorCommittish, querySize, commitsChan)
+		go commits(cl, targetRepoInfo.Path, targetRepoInfo.Repo, targetRepoInfo.Committish, ancestorCommittish, querySize, commitsChan)
 	}
 	for i := 0; i < len(targetRepos); i++ {
 		res := <-commitsChan
@@ -187,16 +229,17 @@
 			outputChan <- additionsResult{Err: res.Err}
 			return
 		}
-		sourceSHA := ""
-		if sha, ok := sourceRepos[res.RepoURL]; ok {
-			sourceSHA = sha.Committish
+		var sourceSHA string
+		if sourceData, ok := sourceRepos[repoID(res.Repo, res.Path)]; ok {
+			sourceSHA = sourceData.Committish
 		}
 		if len(res.Commits) > 0 {
-			repoCommits[res.RepoURL] = &RepoLog{
+			repoCommits[res.Path] = &RepoLog{
 				Commits:        res.Commits,
 				HasMoreCommits: res.HasMoreCommits,
+				Repo:           res.Repo,
 				SourceSHA:      sourceSHA,
-				TargetSHA:      targetRepos[res.RepoURL].Committish,
+				TargetSHA:      targetRepos[repoID(res.Repo, res.Path)].Committish,
 			}
 		}
 	}
@@ -227,9 +270,10 @@
 //
 // The second changelog contains all commits that are present in the source build
 // but not present in the target build
-func Changelog(httpClient *http.Client, sourceBuildNum string, targetBuildNum string, host string, repo string, querySize int) (map[string]*RepoLog, map[string]*RepoLog, error) {
+func Changelog(httpClient *http.Client, sourceBuildNum string, targetBuildNum string, host string, repo string, querySize int) (map[string]*RepoLog, map[string]*RepoLog, utils.ChangelogError) {
 	if httpClient == nil {
-		return nil, nil, errors.New("Changelog: httpClient should not be nil")
+		log.Error("httpClient is nil")
+		return nil, nil, utils.InternalError
 	}
 
 	log.Infof("Retrieving changelog between %s and %s\n", sourceBuildNum, targetBuildNum)
@@ -237,29 +281,27 @@
 
 	// Since the manifest file is always in the cos instance, add cos client
 	// so that client knows what URL to use
-	manifestClient, err := gerritClient(httpClient, host)
+	manifestClient, err := gitilesClient(httpClient, host)
 	if err != nil {
-		return nil, nil, fmt.Errorf("Changelog: error creating client for GoB instance: %s:\n%w", host, err)
+		return nil, nil, err
 	}
 	sourceRepos, err := mappedManifest(manifestClient, repo, sourceBuildNum)
 	if err != nil {
-		return nil, nil, fmt.Errorf("Changelog: error retrieving mapped manifest for source build number: %s using manifest repository: %s:\n%w",
-			sourceBuildNum, repo, err)
+		return nil, nil, err
 	}
 	targetRepos, err := mappedManifest(manifestClient, repo, targetBuildNum)
 	if err != nil {
-		return nil, nil, fmt.Errorf("Changelog: error retrieving mapped manifest for target build number: %s using manifest repository: %s:\n%w",
-			targetBuildNum, repo, err)
+		return nil, nil, err
 	}
 
 	clients[host] = manifestClient
-	err = createGerritClients(clients, httpClient, sourceRepos)
+	err = createGitilesClients(clients, httpClient, sourceRepos)
 	if err != nil {
-		return nil, nil, fmt.Errorf("Changelog: error creating source clients:\n%w", err)
+		return nil, nil, err
 	}
-	err = createGerritClients(clients, httpClient, targetRepos)
+	err = createGitilesClients(clients, httpClient, targetRepos)
 	if err != nil {
-		return nil, nil, fmt.Errorf("Changelog: error creating target clients:\n%w", err)
+		return nil, nil, err
 	}
 
 	addChan := make(chan additionsResult, 1)
@@ -268,11 +310,11 @@
 	go additions(clients, targetRepos, sourceRepos, querySize, missChan)
 	missRes := <-missChan
 	if missRes.Err != nil {
-		return nil, nil, fmt.Errorf("Changelog: failure when retrieving missed commits:\n%w", missRes.Err)
+		return nil, nil, err
 	}
 	addRes := <-addChan
 	if addRes.Err != nil {
-		return nil, nil, fmt.Errorf("Changelog: failure when retrieving commit additions:\n%w", addRes.Err)
+		return nil, nil, err
 	}
 
 	return addRes.Additions, missRes.Additions, nil
diff --git a/src/pkg/changelog/changelog_test.go b/src/pkg/changelog/changelog_test.go
index c7ebdcd..c379392 100644
--- a/src/pkg/changelog/changelog_test.go
+++ b/src/pkg/changelog/changelog_test.go
@@ -51,17 +51,17 @@
 	return true
 }
 
-func repoListInLog(log map[string]*RepoLog, check []string) bool {
+func repoListInLog(log map[string]*RepoLog, check []string) error {
 	for _, check := range check {
 		if log, ok := log[check]; !ok || len(log.Commits) == 0 {
-			return false
+			return fmt.Errorf("Repo path %s not in log", check)
 		}
 	}
-	return true
+	return nil
 }
 
 func TestChangelog(t *testing.T) {
-	httpClient, err := getHTTPClient()
+	httpClient, _ := getHTTPClient()
 
 	// Test invalid source
 	additions, removals, err := Changelog(httpClient, "15", "15043.0.0", cosInstance, defaultManifestRepo, -1)
@@ -71,6 +71,8 @@
 		t.Errorf("changelog failed, expected nil removals, got %v", removals)
 	} else if err == nil {
 		t.Errorf("changelog failed, expected error, got nil")
+	} else if err.HTTPCode() != "404" {
+		t.Errorf("changelog failed, expected error code 404, got error code %s", err.HTTPCode())
 	}
 
 	// Test invalid target
@@ -81,6 +83,8 @@
 		t.Errorf("changelog failed, expected nil removals, got %v", removals)
 	} else if err == nil {
 		t.Errorf("changelog failed, expected error, got nil")
+	} else if err.HTTPCode() != "404" {
+		t.Errorf("changelog failed, expected error code 404, got error code %s", err.HTTPCode())
 	}
 
 	// Test invalid instance
@@ -91,6 +95,8 @@
 		t.Errorf("changelog failed, expected nil removals, got %v", removals)
 	} else if err == nil {
 		t.Errorf("changelog failed, expected error, got nil")
+	} else if err.HTTPCode() != "500" {
+		t.Errorf("changelog failed, expected error code 500, got error code %s", err.HTTPCode())
 	}
 
 	// Test invalid manifest repo
@@ -101,6 +107,8 @@
 		t.Errorf("changelog failed, expected nil removals, got %v", removals)
 	} else if err == nil {
 		t.Errorf("changelog failed, expected error, got nil")
+	} else if err.HTTPCode() != "404" {
+		t.Errorf("changelog failed, expected error code 404, got error code %s", err.HTTPCode())
 	}
 
 	// Test build number higher than latest release
@@ -111,6 +119,8 @@
 		t.Errorf("changelog failed, expected nil removals, got %v", removals)
 	} else if err == nil {
 		t.Errorf("changelog failed, expected error, got nil")
+	} else if err.HTTPCode() != "404" {
+		t.Errorf("changelog failed, expected error code 404, got error code %s", err.HTTPCode())
 	}
 
 	// Test manifest with remote urls specified and no default URL
@@ -246,11 +256,11 @@
 	} else if len(additions) != 1 {
 		t.Errorf("changelog failed, expected only 1 repo in additions, got %v", additions)
 	}
-	boardOverlayLog := additions["cos/overlays/board-overlays"]
+	boardOverlayLog := additions["src/overlays"]
 	if boardOverlayLog == nil {
-		t.Errorf("changelog failed, expected cos/overlays/board-overlays in changelog, got nil")
+		t.Errorf("Changelog failed, expected src/overlays in changelog, got nil")
 	} else if changes := boardOverlayLog.Commits; len(changes) != 108 {
-		t.Errorf("changelog failed, expected 108 changes for \"cos/overlays/board-overlays\", got %d", len(changes))
+		t.Errorf("Changelog failed, expected 108 changes for \"src/overlays\", got %d", len(changes))
 	} else if !commitsMatch(boardOverlayLog.Commits, expectedCommits) {
 		t.Errorf("changelog failed, Changelog output does not match expected commits or is not sorted")
 	} else if boardOverlayLog.SourceSHA != "612ca5ef5455534127d008e08c65aa29a2fd97a5" {
@@ -261,37 +271,35 @@
 
 	// Test build numbers further apart from each other with multiple repo differences
 	// Also ensures that removals are correctly populated
-	source = "15020.0.0"
+	source = "15030.0.0"
 	target = "15056.0.0"
 	additionRepos := []string{
-		"mirrors/cros/chromiumos/platform/crosutils",
-		"cos/manifest",
-		"mirrors/cros/chromiumos/platform/vboot_reference",
-		"mirrors/cros/chromiumos/platform/dev-util",
-		"mirrors/cros/chromiumos/platform/crostestutils",
-		"mirrors/cros/chromiumos/infra/proto",
-		"mirrors/cros/chromiumos/third_party/toolchain-utils",
-		"mirrors/cros/chromiumos/third_party/coreboot",
-		"cos/overlays/board-overlays",
-		"mirrors/cros/chromiumos/platform2",
-		"mirrors/cros/chromiumos/overlays/eclass-overlay",
-		"mirrors/cros/chromiumos/chromite",
-		"mirrors/cros/chromiumos/third_party/autotest",
-		"mirrors/cros/chromiumos/overlays/chromiumos-overlay",
-		"third_party/kernel",
-		"mirrors/cros/chromium/tools/depot_tools",
-		"mirrors/cros/chromiumos/repohooks",
-		"mirrors/cros/chromiumos/overlays/portage-stable",
+		"src/scripts",
+		"src/platform/vboot_reference",
+		"src/platform/dev",
+		"chromite",
+		"src/third_party/autotest/files",
+		"src/third_party/eclass-overlay",
+		"src/third_party/toolchain-utils",
+		"src/platform/crostestutils",
+		"src/third_party/coreboot",
+		"src/third_party/kernel/v5.4",
+		"src/overlays",
+		"src/chromium/depot_tools",
+		"src/third_party/portage-stable",
+		"chromite/infra/proto",
+		"manifest",
+		"src/platform2",
+		"src/third_party/chromiumos-overlay",
 	}
 	additions, removals, err = Changelog(httpClient, source, target, cosInstance, defaultManifestRepo, -1)
 	if err != nil {
 		t.Errorf("changelog failed, expected no error, got %v", err)
 	}
-	kernelLog := additions["third_party/kernel"]
-	if len(removals) != 1 || kernelLog == nil {
-		t.Errorf("changelog failed, expected miss list containing only \"third_party/kernel\", got %v", removals)
-	} else if !repoListInLog(additions, additionRepos) {
-		t.Errorf("changelog failed, additions repo output does not match expected repos %v", additionRepos)
+	if len(removals) != 0 {
+		t.Errorf("Changelog failed, expected empty removals, got %v", removals)
+	} else if err := repoListInLog(additions, additionRepos); err != nil {
+		t.Errorf("Changelog failed, additions repo output does not match expected repos: %v", err)
 	}
 
 	// Test changelog returns correct output when given a querySize instead of -1
@@ -304,18 +312,18 @@
 	} else if additions == nil {
 		t.Errorf("changelog failed, non-empty expected additions, got nil")
 	} else if removals == nil {
-		t.Errorf("changelog failed, non-empty expected removals, got nil")
-	} else if _, ok := additions["third_party/kernel"]; !ok {
-		t.Errorf("changelog failed, expected repo: third_party/kernel in additions")
+		t.Errorf("Changelog failed, non-empty expected removals, got nil")
+	} else if _, ok := additions["src/third_party/kernel/v5.4"]; !ok {
+		t.Errorf("Changelog failed, expected repo: src/third_party/kernel/v4.19 in additions")
 	}
 	for repoName, repoLog := range additions {
 		if repoLog.Commits == nil || len(repoLog.Commits) == 0 {
 			t.Errorf("changelog failed, expected non-empty additions commits, got nil or empty commits")
 		}
 		if len(repoLog.Commits) > querySize {
-			t.Errorf("changelog failed, expected %d commits for repo: %s, got: %d", querySize, repoName, len(repoLog.Commits))
-		} else if repoName == "third_party/kernel" && !repoLog.HasMoreCommits {
-			t.Errorf("changelog failed, expected HasMoreCommits = True for repo: third_party/kernel, got False")
+			t.Errorf("Changelog failed, expected %d commits for repo: %s, got: %d", querySize, repoName, len(repoLog.Commits))
+		} else if repoName == "src/third_party/kernel/v5.4" && !repoLog.HasMoreCommits {
+			t.Errorf("Changelog failed, expected HasMoreCommits = True for repo: src/third_party/kernel/v5.4, got False")
 		} else if repoLog.HasMoreCommits && len(repoLog.Commits) < querySize {
 			t.Errorf("changelog failed, expected HasMoreCommits = False for repo: %s with %d commits, got True", repoName, len(repoLog.Commits))
 		}
@@ -328,47 +336,61 @@
 	if err != nil {
 		t.Errorf("changelog failed, expected no error, got %v", err)
 	} else if len(removals) != 0 {
-		t.Errorf("changelog failed, expected empty removals, got %v", removals)
-	} else if _, ok := additions["cos/cobble"]; !ok {
-		t.Errorf("changelog failed, expected repo: third_party/kernel in additions")
+		t.Errorf("Changelog failed, expected empty removals, got %v", removals)
+	} else if _, ok := additions["src/platform/cobble"]; !ok {
+		t.Errorf("Changelog failed, expected repo: src/third_party/kernel/v4.19 in additions")
 	}
 	for repoName, repoLog := range additions {
 		if repoLog.Commits == nil || len(repoLog.Commits) == 0 {
-			t.Errorf("changelog failed, expected non-empty additions commits, got nil or empty commits")
-		} else if repoName == "cos/cobble" {
+			t.Errorf("Changelog failed, expected non-empty additions commits, got nil or empty commits")
+		} else if repoName == "src/platform/cobble" {
 			if repoLog.HasMoreCommits {
-				t.Errorf("changelog failed, expected hasMoreCommits = false for repo: cos/cobble, got true")
+				t.Errorf("Changelog failed, expected hasMoreCommits = false for repo: src/platform/cobble, got true")
 			} else if repoLog.SourceSHA != "" {
-				t.Errorf("changelog failed, expected empty SourceSHA for cos/cobble, got %s", repoLog.SourceSHA)
+				t.Errorf("Changelog failed, expected empty SourceSHA for src/platform/cobble, got %s", repoLog.SourceSHA)
 			} else if repoLog.TargetSHA != "4ab43f1f86b7099b8ad75cf9615ea1fa155bbd7d" {
-				t.Errorf("changelog failed, expected TargetSHA: \"4ab43f1f86b7099b8ad75cf9615ea1fa155bbd7d\" for cos/cobble, got %s", repoLog.TargetSHA)
+				t.Errorf("Changelog failed, expected TargetSHA: \"4ab43f1f86b7099b8ad75cf9615ea1fa155bbd7d\" for src/platform/cobble, got %s", repoLog.TargetSHA)
 			}
 		}
 	}
-	// Test repository added between builds
+
+	// Test changelog handles new repository addition
 	source = "12871.1179.0"
 	target = "12871.1177.0"
 	additions, removals, err = Changelog(httpClient, source, target, cosInstance, defaultManifestRepo, querySize)
 	if err != nil {
 		t.Errorf("changelog failed, expected no error, got %v", err)
 	} else if len(additions) != 0 {
-		t.Errorf("changelog failed, expected empty additions, got %v", additions)
-	} else if _, ok := removals["cos/cobble"]; !ok {
-		t.Errorf("changelog failed, expected repo: third_party/kernel in additions")
+		t.Errorf("Changelog failed, expected empty additions, got %v", additions)
+	} else if _, ok := removals["src/platform/cobble"]; !ok {
+		t.Errorf("Changelog failed, expected repo: src/third_party/kernel/v4.19 in additions")
 	}
 	for repoName, repoLog := range removals {
 		if repoLog.Commits == nil || len(repoLog.Commits) == 0 {
-			t.Errorf("changelog failed, expected non-empty additions commits, got nil or empty commits")
-		} else if repoName == "cos/cobble" {
+			t.Errorf("Changelog failed, expected non-empty additions commits, got nil or empty commits")
+		} else if repoName == "src/platform/cobble" {
 			if repoLog.HasMoreCommits {
-				t.Errorf("changelog failed, expected hasMoreCommits = false for repo: cos/cobble, got true")
+				t.Errorf("Changelog failed, expected hasMoreCommits = false for repo: src/platform/cobble, got true")
 			} else if repoLog.SourceSHA != "" {
-				t.Errorf("changelog failed, expected empty SourceSHA for cos/cobble, got %s", repoLog.SourceSHA)
+				t.Errorf("Changelog failed, expected empty SourceSHA for src/platform/cobble, got %s", repoLog.SourceSHA)
 			} else if repoLog.TargetSHA != "4ab43f1f86b7099b8ad75cf9615ea1fa155bbd7d" {
-				t.Errorf("changelog failed, expected TargetSHA: \"4ab43f1f86b7099b8ad75cf9615ea1fa155bbd7d\" for cos/cobble, got %s", repoLog.TargetSHA)
+				t.Errorf("Changelog failed, expected TargetSHA: \"4ab43f1f86b7099b8ad75cf9615ea1fa155bbd7d\" for src/platform/cobble, got %s", repoLog.TargetSHA)
 			}
 		}
 	}
+
+	// Test with different release branches
+	source = "13310.1035.0"
+	target = "15000.0.0"
+	additions, removals, err = Changelog(httpClient, source, target, cosInstance, defaultManifestRepo, querySize)
+	if err != nil {
+		t.Errorf("Changelog failed, expected no error, got %v", err)
+	} else if len(additions) == 0 {
+		t.Errorf("Changelog failed, expected non-empty additions, got %v", additions)
+	} else if len(removals) == 0 {
+		t.Errorf("Changelog failed, expected non-empty removals, got %v", removals)
+	}
+
 	// Test empty repository
 	source = "0.0.0"
 	target = "2.0.0"
@@ -379,5 +401,7 @@
 		t.Errorf("changelog failed, expected nil removals, got %v", removals)
 	} else if err == nil {
 		t.Errorf("changelog failed, expected error, got nil")
+	} else if err.HTTPCode() != "500" {
+		t.Errorf("changelog failed, expected error code 500, got error code %s", err.HTTPCode())
 	}
 }
diff --git a/src/pkg/changelog/gitcommit.go b/src/pkg/changelog/gitcommit.go
index 39d49f4..692ff79 100755
--- a/src/pkg/changelog/gitcommit.go
+++ b/src/pkg/changelog/gitcommit.go
@@ -27,14 +27,6 @@
 	releaseNoteLinePrefix string = "RELEASE_NOTE="
 )
 
-// RepoLog contains a changelist for a particular repository
-type RepoLog struct {
-	Commits        []*Commit
-	SourceSHA      string
-	TargetSHA      string
-	HasMoreCommits bool
-}
-
 // Commit is a simplified struct of git.Commit
 // Useful for interfaces
 type Commit struct {
@@ -122,7 +114,7 @@
 // Commit object with processed fields
 func parseGitCommit(commit *git.Commit) (*Commit, error) {
 	if commit == nil {
-		return nil, errors.New("ParseCommit: Input should not be nil")
+		return nil, errors.New("parseCommit: Input should not be nil")
 	}
 	return &Commit{
 		SHA:           commit.Id,
@@ -139,13 +131,13 @@
 // into a slice of Commit objects with processed fields
 func ParseGitCommitLog(commits []*git.Commit) ([]*Commit, error) {
 	if commits == nil {
-		return nil, errors.New("ParseCommitLog: Input should not be nil")
+		return nil, errors.New("parseCommitLog: Input should not be nil")
 	}
 	output := make([]*Commit, len(commits))
 	for i, commit := range commits {
 		parsedCommit, err := parseGitCommit(commit)
 		if err != nil {
-			return nil, errors.New("ParseCommitLog: Input slice contains nil pointer")
+			return nil, errors.New("parseCommitLog: Input slice contains nil pointer")
 		}
 		output[i] = parsedCommit
 	}
diff --git a/src/pkg/findbuild/findbuild.go b/src/pkg/findbuild/findbuild.go
index 11814e3..4f32607 100644
--- a/src/pkg/findbuild/findbuild.go
+++ b/src/pkg/findbuild/findbuild.go
@@ -35,7 +35,6 @@
 
 import (
 	"context"
-	"errors"
 	"fmt"
 	"net/http"
 	"regexp"
@@ -76,21 +75,6 @@
 			defaultRelease: "master",
 		},
 	}
-
-	// ErrorCLNotLanded indicates no build contains the requested CL
-	ErrorCLNotLanded = errors.New("No build found containing CL")
-	// ErrorCLNotFound indicates that the CL could not be found in the provided
-	// Gerrit instance
-	ErrorCLNotFound = errors.New("CL not found in provided Gerrit instance")
-	// ErrorIDNotUnique indicates that a provided CL identifier maps to multiple
-	// CLs. This could happen if a Change-ID is provided instead of a CL number or
-	// commit SHA, since CLs that are cherry-picked are grouped together by
-	// Change-ID.
-	ErrorIDNotUnique = errors.New("Provided change identifier does not map to a unique commit")
-	// ErrorNoManifestBranchMatch indicates that the target CL was for a change
-	// in a repository + branch that is not used in any builds within the CL's
-	// submission time frame.
-	ErrorNoManifestBranchMatch = errors.New("No manifest files were found containing a matching repository and branch as the target CL")
 )
 
 // BuildRequest is the input struct for the FindBuild function
@@ -127,7 +111,7 @@
 }
 
 type clData struct {
-	CLNum    int
+	CLNum    string
 	Project  string
 	Release  string
 	Branch   string
@@ -149,40 +133,49 @@
 	Err       error
 }
 
-// clList retrieves the list of CLs matching a query from Gerrit
-func clList(client *gerrit.Client, clID string) ([]*gerrit.Change, error) {
+// queryCL retrieves the list of CLs matching a query from Gerrit
+func queryCL(client *gerrit.Client, clID string) (*gerrit.Change, utils.ChangelogError) {
 	log.Debug("Retrieving CL List from Gerrit")
 	queryOptions := gerrit.ChangeQueryParams{
 		Query:   clID,
-		N:       2,
+		N:       1,
 		Options: []string{"CURRENT_REVISION"},
 	}
 	ctx, cancel := context.WithTimeout(context.Background(), requestMaxAge)
 	defer cancel()
 	clList, _, err := client.ChangeQuery(ctx, queryOptions)
-	return clList, err
-}
-
-func getCLData(client *gerrit.Client, clID string, manifestPrefix string) (*clData, error) {
-	log.Debugf("Retrieving CL data from Gerrit for changeID: %s", clID)
-	clList, err := clList(client, clID)
 	if err != nil {
-		return nil, fmt.Errorf("getCLData: Error retrieving change:\n%w", err)
+		log.Errorf("queryCL: Error retrieving change for input %s:\n%v", clID, err)
+		httpCode := utils.GerritErrCode(err)
+		return nil, utils.FromFindBuildError(httpCode, clID)
 	}
-	// A user provided changeID should only map to one CL
 	if len(clList) == 0 {
-		return nil, ErrorCLNotFound
-	} else if len(clList) > 1 {
-		return nil, ErrorIDNotUnique
+		log.Errorf("queryCL: CL with identifier %s not found", clID)
+		return nil, utils.FromFindBuildError("404", clID)
 	}
 	change := clList[0]
+	if change.ChangeID == clID {
+		log.Debugf("Provided CL identifier %s is a Change-ID, should be CL num or commit SHA", clID)
+		return nil, utils.FromFindBuildError("400", clID)
+	}
 	if change.Submitted == "" {
-		return nil, fmt.Errorf("No submitted CL found")
+		log.Debugf("Provided CL identifier %s maps to an unsubmitted CL", clID)
+		return nil, utils.FromFindBuildError("406", clID)
+	}
+	return change, nil
+}
+
+func getCLData(client *gerrit.Client, clID string, manifestPrefix string) (*clData, utils.ChangelogError) {
+	log.Debugf("Retrieving CL data from Gerrit for changeID: %s", clID)
+	change, err := queryCL(client, clID)
+	if err != nil {
+		return nil, err
 	}
 	log.Debugf("Target CL found with SHA %s on repo %s, branch %s", change.CurrentRevision, change.Project, change.Branch)
-	var parsedTime time.Time
-	if parsedTime, err = time.Parse("2006-01-02 15:04:05.000000000", change.Submitted); err != nil {
-		return nil, fmt.Errorf("getTargetData: Error parsing CL submit time:\n%w", err)
+	parsedTime, parseErr := time.Parse("2006-01-02 15:04:05.000000000", change.Submitted)
+	if parseErr != nil {
+		log.Errorf("getTargetData: Error parsing submission time %s for CL %d:\n%v", change.Submitted, change.ChangeNumber, err)
+		return nil, utils.InternalError
 	}
 	// If a repository has non-conventional branch names, need to convert the
 	// repository branch name to a release branch name
@@ -195,7 +188,7 @@
 		}
 	}
 	return &clData{
-		CLNum:    change.ChangeNumber,
+		CLNum:    strconv.Itoa(change.ChangeNumber),
 		Project:  manifestPrefix + change.Project,
 		Release:  release,
 		Branch:   change.Branch,
@@ -207,11 +200,12 @@
 // candidateManifestCommits returns a list of commits to the manifest-snapshot
 // repo that were committed within a time range from the target commit time, in
 // reverse chronological order.
-func candidateManifestCommits(client gitilesProto.GitilesClient, manifestRepo string, targetData *clData) ([]*git.Commit, error) {
+func candidateManifestCommits(client gitilesProto.GitilesClient, manifestRepo string, targetData *clData) ([]*git.Commit, utils.ChangelogError) {
 	log.Debugf("Retrieving all manifest snapshots committed within %d days of CL submission", endBuildTime)
 	allManifests, _, err := utils.Commits(client, manifestRepo, "refs/heads/"+targetData.Release, "", -1)
 	if err != nil {
-		return nil, err
+		httpCode := utils.GitilesErrCode(err)
+		return nil, utils.FromFindBuildError(httpCode, targetData.CLNum)
 	}
 	// Find latest commit that occurs before the target commit time.
 	// allManifests is in reverse chronological order.
@@ -219,7 +213,8 @@
 	for left < right {
 		mid := (left + right) / 2
 		if allManifests[mid].Committer == nil {
-			return nil, fmt.Errorf("Manifest %s has no committer", allManifests[mid].Id)
+			log.Errorf("manifest %s has no committer", allManifests[mid].Id)
+			return nil, utils.InternalError
 		}
 		currDate := allManifests[mid].Committer.Time.AsTime()
 		if currDate.Before(targetData.Time) {
@@ -236,7 +231,8 @@
 	for left < right {
 		mid := (left+right)/2 + 1
 		if allManifests[mid].Committer == nil {
-			return nil, fmt.Errorf("Manifest %s has no committer", allManifests[mid].Id)
+			log.Errorf("manifest %s has no committer", allManifests[mid].Id)
+			return nil, utils.InternalError
 		}
 		currDate := allManifests[mid].Committer.Time.AsTime()
 		if currDate.After(endDate) {
@@ -246,6 +242,9 @@
 		}
 	}
 	latestIdx := right
+	if allManifests[earliestIdx].Committer.Time.AsTime().After(targetData.Time) {
+		return nil, utils.FromFindBuildError("404", targetData.CLNum)
+	}
 	return allManifests[latestIdx : earliestIdx+1], nil
 }
 
@@ -258,7 +257,12 @@
 	}
 	ctx, cancel := context.WithTimeout(context.Background(), requestMaxAge)
 	defer cancel()
-	return client.Refs(ctx, request)
+	res, err := client.Refs(ctx, request)
+	if err != nil {
+		log.Errorf("error retrieving tags:\n%v", err)
+		return nil, err
+	}
+	return res, nil
 }
 
 // candidateBuildNums returns a list of build numbers from a list of possible
@@ -267,14 +271,16 @@
 // could be a candidate. It then retrieves a mapping of build number -> commit SHA,
 // for all commits in the manifest repo, and compares it with the candidate
 // list to create a list of build numbers.
-func candidateBuildNums(client gitilesProto.GitilesClient, manifestRepo string, targetData *clData) ([]string, error) {
-	manifestCommits, err := candidateManifestCommits(client, manifestRepo, targetData)
-	if err != nil {
-		return nil, err
+func candidateBuildNums(client gitilesProto.GitilesClient, manifestRepo string, targetData *clData) ([]string, utils.ChangelogError) {
+	manifestCommits, utilErr := candidateManifestCommits(client, manifestRepo, targetData)
+	if utilErr != nil {
+		return nil, utilErr
 	}
 	tags, err := repoTags(client, manifestRepo)
 	if err != nil {
-		return nil, fmt.Errorf("tagMapping: Failed to retrieve tags for project %s:\n%w", manifestRepo, err)
+		log.Errorf("tagMapping: Failed to retrieve tags for project %s:\n%v", manifestRepo, err)
+		httpCode := utils.GerritErrCode(err)
+		return nil, utils.FromFindBuildError(httpCode, targetData.CLNum)
 	}
 	log.Debug("Retrieving associated build number for each manifest commit")
 	gitTagsMap := map[string]string{}
@@ -285,10 +291,11 @@
 	for i, commit := range manifestCommits {
 		tag, ok := gitTagsMap[commit.Id]
 		if !ok {
-			return nil, fmt.Errorf("candidateBuildNums: No ref tag found for commit sha %s in repository %s", commit.Id, manifestRepo)
+			log.Errorf("candidateBuildNums: No ref tag found for commit sha %s in repository %s", commit.Id, manifestRepo)
+			return nil, utils.InternalError
 		} else if len(tag) <= 10 {
-			// Git tags should be of the form refs/tags/<buildNum>
-			return nil, fmt.Errorf("candidateBuildNums: Ref tag: %s for commit sha %s is malformed", tag, commit.Id)
+			log.Errorf("candidateBuildNums: Ref tag: %s for commit sha %s is malformed", tag, commit.Id)
+			return nil, utils.InternalError
 		}
 		// Remove refs/tags/ prefix for each git tag
 		output[i] = gitTagsMap[commit.Id][10:]
@@ -349,7 +356,7 @@
 // getRepoData retrieves information about the repository being modified by the
 // CL. It retrieves candidate build numbers and their associated SHA, the
 // the first and last SHA in the repository changelog, and the remote URL.
-func getRepoData(client gitilesProto.GitilesClient, manifestRepo string, targetData *clData) (*repoData, error) {
+func getRepoData(client gitilesProto.GitilesClient, manifestRepo string, targetData *clData) (*repoData, utils.ChangelogError) {
 	buildNums, err := candidateBuildNums(client, manifestRepo, targetData)
 	if err != nil {
 		return nil, err
@@ -377,7 +384,8 @@
 			continue
 		}
 		if output.RemoteURL != "" && output.RemoteURL != curr.RemoteURL {
-			return nil, fmt.Errorf("getRepoData: Remote URL for repository %s changed in build %s", targetData.Project, curr.BuildNum)
+			log.Errorf("getRepoData: Remote URL for repository %s changed in build %s", targetData.Project, curr.BuildNum)
+			return nil, utils.InternalError
 		}
 		output.RemoteURL = curr.RemoteURL
 		// Since a manifest file may not use the repository/branch used by a
@@ -395,14 +403,15 @@
 		}
 	}
 	if len(output.Candidates) == 0 {
-		return nil, ErrorNoManifestBranchMatch
+		log.Debugf("getRepoData: No builds found for CL %s", targetData.CLNum)
+		return nil, utils.FromFindBuildError("404", targetData.CLNum)
 	}
 	return &output, nil
 }
 
 // firstBuild retrieves the earliest build containing the target CL from a map
 // of candidate builds.
-func firstBuild(changelog []*git.Commit, targetData *clData, candidates map[string]string) (string, error) {
+func firstBuild(changelog []*git.Commit, targetData *clData, candidates map[string]string) (string, utils.ChangelogError) {
 	log.Debug("Scanning changelog for first build")
 	targetIdx := -1
 	for i, commit := range changelog {
@@ -411,7 +420,7 @@
 		}
 	}
 	if targetIdx == -1 {
-		return "", ErrorCLNotLanded
+		return "", utils.FromFindBuildError("404", targetData.CLNum)
 	}
 	for i := targetIdx; i >= 0; i-- {
 		currSHA := changelog[i].Id
@@ -419,51 +428,56 @@
 			return buildNum, nil
 		}
 	}
-	return "", ErrorCLNotLanded
+	return "", utils.FromFindBuildError("404", targetData.CLNum)
 }
 
 // FindBuild locates the first build that a CL was introduced to.
-func FindBuild(request *BuildRequest) (*BuildResponse, error) {
+func FindBuild(request *BuildRequest) (*BuildResponse, utils.ChangelogError) {
 	log.Debugf("Fetching first build for CL: %s", request.CL)
-	if request == nil {
-		return nil, fmt.Errorf("FindCL: request is nil")
-	}
 	start := time.Now()
+	if request == nil {
+		log.Error("expected non-nil request")
+		return nil, utils.InternalError
+	}
 	gerritClient, err := gerrit.NewClient(request.HTTPClient, request.GerritHost)
 	if err != nil {
-		return nil, fmt.Errorf("FindCL: Error creating Gerrit Client:\n%w", err)
+		log.Errorf("failed to establish Gerrit client for host %s:\n%v", request.GerritHost, err)
+		return nil, utils.InternalError
 	}
 	gitilesClient, err := gitilesApi.NewRESTClient(request.HTTPClient, request.GitilesHost, true)
 	if err != nil {
-		return nil, fmt.Errorf("FindCL: Error creating Gitiles Client:\n%w", err)
+		log.Errorf("failed to establish Gitiles client for host %s:\n%v", request.GitilesHost, err)
+		return nil, utils.InternalError
 	}
-	clData, err := getCLData(gerritClient, request.CL, request.RepoPrefix)
-	if err != nil {
-		return nil, fmt.Errorf("FindCL: Error retrieving CL for changeID: %s:\n%w", request.CL, err)
+	clData, clErr := getCLData(gerritClient, request.CL, request.RepoPrefix)
+	if clErr != nil {
+		return nil, clErr
 	}
-	repoData, err := getRepoData(gitilesClient, request.ManifestRepo, clData)
-	if err != nil {
-		return nil, err
+	repoData, clErr := getRepoData(gitilesClient, request.ManifestRepo, clData)
+	if clErr != nil {
+		return nil, clErr
 	}
 	// The remote URL for a repo may not be the same as the manifest remote URL
 	if request.GitilesHost != repoData.RemoteURL {
 		log.Debugf("Repository is located in different GoB host, setting gitiles client to URL: %s", repoData.RemoteURL)
 		gitilesClient, err = gitilesApi.NewRESTClient(request.HTTPClient, repoData.RemoteURL, true)
 		if err != nil {
-			return nil, fmt.Errorf("FindCL: Failed to create gitiles client for repo remote URL: %s\n%w", repoData.RemoteURL, err)
+			log.Errorf("failed to establish Gitiles client for host %s:\n%v", repoData.RemoteURL, err)
+			return nil, utils.InternalError
 		}
 	}
 	changelog, _, err := utils.Commits(gitilesClient, clData.Project, repoData.TargetSHA, repoData.SourceSHA, -1)
 	if err != nil {
-		return nil, err
+		httpCode := utils.GerritErrCode(err)
+		return nil, utils.FromFindBuildError(httpCode, clData.CLNum)
 	}
-	buildNum, err := firstBuild(changelog, clData, repoData.Candidates)
-	if err != nil {
-		return nil, err
+	buildNum, clErr := firstBuild(changelog, clData, repoData.Candidates)
+	if clErr != nil {
+		return nil, clErr
 	}
 	log.Debugf("Retrieved first build for CL: %s in %s\n", request.CL, time.Since(start))
 	return &BuildResponse{
 		BuildNum: buildNum,
-		CLNum:    strconv.Itoa(clData.CLNum),
+		CLNum:    clData.CLNum,
 	}, nil
 }
diff --git a/src/pkg/findbuild/findbuild_test.go b/src/pkg/findbuild/findbuild_test.go
index 31b6b8e..bf8b13c 100644
--- a/src/pkg/findbuild/findbuild_test.go
+++ b/src/pkg/findbuild/findbuild_test.go
@@ -16,7 +16,6 @@
 
 import (
 	"context"
-	"errors"
 	"fmt"
 	"net/http"
 	"testing"
@@ -34,14 +33,6 @@
 	fallbackRepoPrefix        string = "mirrors/cros/"
 )
 
-func unwrappedError(err error) error {
-	innerErr := err
-	for errors.Unwrap(innerErr) != nil {
-		innerErr = errors.Unwrap(innerErr)
-	}
-	return innerErr
-}
-
 func getHTTPClient() (*http.Client, error) {
 	creds, err := google.FindDefaultCredentials(context.Background(), gerrit.OAuthScope)
 	if err != nil || len(creds.JSON) == 0 {
@@ -60,7 +51,7 @@
 		ManifestRepo       string
 		OutputBuildNum     string
 		ShouldFallback     bool
-		ShouldError        bool
+		ExpectedError      string
 	}{
 		"invalid host": {
 			Change:         "3781",
@@ -68,7 +59,7 @@
 			GitilesHost:    "zop.googlesource.com",
 			ManifestRepo:   externalManifestRepo,
 			ShouldFallback: false,
-			ShouldError:    true,
+			ExpectedError:  "500",
 		},
 		"incorrect manifest repo": {
 			Change:         "3781",
@@ -76,7 +67,7 @@
 			GitilesHost:    externalGitilesURL,
 			ManifestRepo:   "cos/manifest",
 			ShouldFallback: false,
-			ShouldError:    true,
+			ExpectedError:  "500",
 		},
 		"master branch release version": {
 			Change:         "3280",
@@ -85,7 +76,6 @@
 			ManifestRepo:   externalManifestRepo,
 			OutputBuildNum: "15085.0.0",
 			ShouldFallback: false,
-			ShouldError:    false,
 		},
 		"R85-13310.B branch release version": {
 			Change:         "3206",
@@ -94,7 +84,6 @@
 			ManifestRepo:   externalManifestRepo,
 			OutputBuildNum: "13310.1025.0",
 			ShouldFallback: false,
-			ShouldError:    false,
 		},
 		"only CL in build diff": {
 			Change:         "3781",
@@ -103,7 +92,6 @@
 			ManifestRepo:   externalManifestRepo,
 			OutputBuildNum: "12371.1072.0",
 			ShouldFallback: false,
-			ShouldError:    false,
 		},
 		"non-existant CL": {
 			Change:             "9999999999",
@@ -112,7 +100,7 @@
 			FallbackGerritHost: externalFallbackGerritURL,
 			ManifestRepo:       externalManifestRepo,
 			ShouldFallback:     true,
-			ShouldError:        true,
+			ExpectedError:      "404",
 		},
 		"abandoned CL": {
 			Change:         "3743",
@@ -120,7 +108,7 @@
 			GitilesHost:    externalGitilesURL,
 			ManifestRepo:   externalManifestRepo,
 			ShouldFallback: false,
-			ShouldError:    true,
+			ExpectedError:  "406",
 		},
 		"under review CL": {
 			Change:         "1540",
@@ -128,7 +116,7 @@
 			GitilesHost:    externalGitilesURL,
 			ManifestRepo:   externalManifestRepo,
 			ShouldFallback: false,
-			ShouldError:    true,
+			ExpectedError:  "406",
 		},
 		"chromium CL": {
 			Change:             "2288114",
@@ -139,7 +127,6 @@
 			FallbackPrefix:     fallbackRepoPrefix,
 			OutputBuildNum:     "15049.0.0",
 			ShouldFallback:     true,
-			ShouldError:        false,
 		},
 		"use commit SHA": {
 			Change:             "80809c436f1cae4cde117fce34b82f38bdc2fd36",
@@ -149,15 +136,14 @@
 			ManifestRepo:       externalManifestRepo,
 			OutputBuildNum:     "12871.1183.0",
 			ShouldFallback:     false,
-			ShouldError:        false,
 		},
-		"reject cherry-picked change-id": {
+		"reject change-id": {
 			Change:         "I6cc721e6e61b3863e549045e68c1a2bd363efa0a",
 			GerritHost:     externalGerritURL,
 			GitilesHost:    externalGitilesURL,
 			ManifestRepo:   externalManifestRepo,
 			ShouldFallback: false,
-			ShouldError:    true,
+			ExpectedError:  "400",
 		},
 		"third_party/kernel special branch case": {
 			Change:         "3302",
@@ -166,7 +152,6 @@
 			ManifestRepo:   externalManifestRepo,
 			OutputBuildNum: "15088.0.0",
 			ShouldFallback: false,
-			ShouldError:    false,
 		},
 		"branch not in manifest": {
 			Change:         "1592",
@@ -174,7 +159,17 @@
 			GitilesHost:    externalGitilesURL,
 			ManifestRepo:   externalManifestRepo,
 			ShouldFallback: false,
-			ShouldError:    true,
+			ExpectedError:  "500",
+		},
+		"cl fallback earlier than earliest COS build": {
+			Change:             "3740",
+			GerritHost:         externalGerritURL,
+			GitilesHost:        externalGitilesURL,
+			FallbackGerritHost: externalFallbackGerritURL,
+			ManifestRepo:       externalManifestRepo,
+			FallbackPrefix:     fallbackRepoPrefix,
+			ShouldFallback:     true,
+			ExpectedError:      "404",
 		},
 	}
 
@@ -190,11 +185,10 @@
 				CL:           test.Change,
 			}
 			res, err := FindBuild(req)
-			innerErr := unwrappedError(err)
-			if innerErr != ErrorCLNotFound && test.ShouldFallback {
+			if err != nil && err.HTTPCode() != "404" && test.ShouldFallback {
 				t.Fatalf("expected not found error, got %v", err)
 			}
-			if innerErr == ErrorCLNotFound {
+			if err != nil && err.HTTPCode() == "404" {
 				fallbackReq := &BuildRequest{
 					HTTPClient:   httpClient,
 					GerritHost:   test.FallbackGerritHost,
@@ -206,13 +200,13 @@
 				res, err = FindBuild(fallbackReq)
 			}
 			switch {
-			case (err != nil) != test.ShouldError:
-				ShouldError := "no error"
-				if test.ShouldError {
-					ShouldError = "some error"
-				}
-				t.Fatalf("expected %s, got: %v", ShouldError, err)
-			case !test.ShouldError && res.BuildNum != test.OutputBuildNum:
+			case test.ExpectedError == "" && err != nil:
+				t.Fatalf("expected no error, got %v", err)
+			case test.ExpectedError != "" && err == nil:
+				t.Fatalf("expected error code %s, got nil err", test.ExpectedError)
+			case test.ExpectedError != "" && err != nil && test.ExpectedError != err.HTTPCode():
+				t.Fatalf("expected error code %s, got error code %s", test.ExpectedError, err.HTTPCode())
+			case test.ExpectedError == "" && res.BuildNum != test.OutputBuildNum:
 				t.Fatalf("expected output %s, got %s", test.OutputBuildNum, res)
 			}
 		})
diff --git a/src/pkg/utils/changelogerror.go b/src/pkg/utils/changelogerror.go
new file mode 100644
index 0000000..52cedc5
--- /dev/null
+++ b/src/pkg/utils/changelogerror.go
@@ -0,0 +1,184 @@
+// Copyright 2020 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// This package contains the error interface returned by changelog and
+// findbuild packages. It includes functions to retrieve HTTP status codes
+// from Gerrit and Gitiles errors, and functions to create ChangelogErrors
+// relevant to the changelog and findbuild features.
+
+package utils
+
+import (
+	"errors"
+	"fmt"
+	"regexp"
+
+	log "github.com/sirupsen/logrus"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+)
+
+var (
+	grpcCodeToHTTP = map[string]string{
+		codes.Unknown.String():            "500",
+		codes.InvalidArgument.String():    "400",
+		codes.NotFound.String():           "404",
+		codes.PermissionDenied.String():   "403",
+		codes.Unauthenticated.String():    "401",
+		codes.ResourceExhausted.String():  "429",
+		codes.FailedPrecondition.String(): "400",
+		codes.OutOfRange.String():         "400",
+		codes.Internal.String():           "500",
+		codes.Unavailable.String():        "503",
+		codes.DataLoss.String():           "500",
+	}
+	httpStatus = map[string]string{
+		"400": "400 Bad Request",
+		"401": "401 Unauthorized",
+		"403": "403 Forbidden",
+		"404": "404 Not Found",
+		"406": "406 Not Acceptable",
+		"429": "429 Too Many Requests",
+		"500": "500 Internal Server Error",
+		"503": "503 Service Unavailable",
+	}
+	// Gitiles specific error messages
+	// Specifies a string format to allow the build number to be inserted
+	httpGitilesErrorFmt = map[string]string{
+		"404": "Build number %s not found. Please input a valid build number (example: 13310.1035.0).",
+	}
+	// Gerrit specific error messages
+	// Specifies a string format to allow CL number to be inserted
+	httpGerritErrorFmt = map[string]string{
+		"400": "%s is not a recognized CL identifier. Please enter either a CL-Number (example: 3206) or a Commit-SHA (example: I7e549d7753cc7acec2b44bb5a305347a97719ab9) for a submitted CL.",
+		"404": "No build found for a CL with ID %s. Please enter either the CL-Number (example: 3206) or a Commit-SHA (example: I7e549d7753cc7acec2b44bb5a305347a97719ab9) of a submitted CL.",
+		"406": "CL identifier %s maps to a CL that has not been submitted yet. A CL will not enter any build until it is successfully submitted. Please provide the CL identifier for a submitted CL.",
+	}
+	// Standard error messages
+	httpErrorReplacement = map[string]string{
+		"403": "This account does not have access to internal repositories. Please retry with an authorized account, or select the external button to query from publically accessible builds.",
+		"429": "Our servers are currently experiencing heavy load. Please retry in a couple minutes.",
+		"500": "An unexpected error occurred while retrieving the requested information.",
+	}
+	gitiles403ErrMsg = "unexpected HTTP 403 from Gitiles"
+	gerritErrCodeRe  = regexp.MustCompile("status code\\s*(\\d+)")
+
+	// InternalError is a ChangelogError object indicating an internal error
+	InternalError = newError("500", httpErrorReplacement["500"])
+)
+
+// ChangelogError is the error type used by the changelog and findbuild package
+type ChangelogError interface {
+	error
+	HTTPCode() string
+	HTTPStatus() string
+}
+
+// UtilChangelogError implements the ChangelogError interface
+type UtilChangelogError struct {
+	httpCode   string
+	httpStatus string
+	err        string
+}
+
+// HTTPCode retrieves the HTTP error code associated with the error
+// ex. 400
+func (e *UtilChangelogError) HTTPCode() string {
+	return e.httpCode
+}
+
+// HTTPStatus retrieves the full HTTP status associated with the error
+// ex. 400 Bad Request
+func (e *UtilChangelogError) HTTPStatus() string {
+	return e.httpStatus
+}
+
+func (e *UtilChangelogError) Error() string {
+	return e.err
+}
+
+func unwrapError(err error) error {
+	innerErr := err
+	for errors.Unwrap(innerErr) != nil {
+		innerErr = errors.Unwrap(innerErr)
+	}
+	return innerErr
+}
+
+// newError creates a new UtilChangelogError
+func newError(httpCode, errString string) *UtilChangelogError {
+	output := UtilChangelogError{
+		httpCode: httpCode,
+		err:      errString,
+	}
+	if header, ok := httpStatus[httpCode]; ok {
+		output.httpStatus = header
+	} else {
+		log.Errorf("No HTTP status mapping for HTTP code %s", httpCode)
+		output.httpStatus = httpStatus["500"]
+	}
+	return &output
+}
+
+// FromChangelogError creates Changelog errors that are relevant to Changelog
+// functionality.
+func FromChangelogError(httpCode, buildNum string) *UtilChangelogError {
+	if errFmt, ok := httpGitilesErrorFmt[httpCode]; ok {
+		errStr := fmt.Sprintf(errFmt, buildNum)
+		return newError("404", errStr)
+	} else if replacementErr, ok := httpErrorReplacement[httpCode]; ok {
+		return newError(httpCode, replacementErr)
+	}
+	return InternalError
+}
+
+// FromFindBuildError creates Changelog errors that are relevant to FindBuild
+// functionality.
+func FromFindBuildError(httpCode string, clID string) *UtilChangelogError {
+	if errFmt, ok := httpGerritErrorFmt[httpCode]; ok {
+		errStr := fmt.Sprintf(errFmt, clID)
+		return newError(httpCode, errStr)
+	} else if replacementErr, ok := httpErrorReplacement[httpCode]; ok {
+		return newError(httpCode, replacementErr)
+	}
+	return InternalError
+}
+
+// GitilesErrCode parses a Gitiles error message and returns an HTTP error code
+// associated with the error. Returns 500 if no error code is found.
+func GitilesErrCode(err error) string {
+	rpcStatus, ok := status.FromError(err)
+	if !ok {
+		return "500"
+	}
+	code, text := rpcStatus.Code(), rpcStatus.Message()
+	// RPC status code misclassifies 403 error as 500 error for Gitiles requests
+	if code == codes.Internal && text == gitiles403ErrMsg {
+		code = codes.PermissionDenied
+	}
+	if httpCode, ok := grpcCodeToHTTP[code.String()]; ok {
+		return httpCode
+	}
+	return "500"
+}
+
+// GerritErrCode parse a Gerrit error and returns an HTTP error code associated
+// with the error. Returns 500 if no error code is found.
+func GerritErrCode(err error) string {
+	matches := gerritErrCodeRe.FindStringSubmatch(err.Error())
+	if len(matches) != 2 {
+		return "500"
+	}
+	return matches[1]
+}
diff --git a/src/pkg/utils/changelogerror_test.go b/src/pkg/utils/changelogerror_test.go
new file mode 100644
index 0000000..357e9ec
--- /dev/null
+++ b/src/pkg/utils/changelogerror_test.go
@@ -0,0 +1,181 @@
+// Copyright 2020 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package utils
+
+import (
+	"errors"
+	"testing"
+
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+)
+
+func TestGerritErrCode(t *testing.T) {
+	tests := map[string]struct {
+		inputErr     error
+		expectedCode string
+	}{
+		"Empty Error": {
+			inputErr:     errors.New(""),
+			expectedCode: "500",
+		},
+		"Mapped Error Code": {
+			inputErr:     errors.New("failed to fetch \"https://cos-internal-review.googlesource.com/a/changes/?n=1&o=CURRENT_REVISION&q=1\", status code 403"),
+			expectedCode: "403",
+		},
+		"Irregular Code": {
+			inputErr:     errors.New("failed to fetch \"https://cos-internal-review.googlesource.com/a/changes/?n=1&o=CURRENT_REVISION&q=1\", status code 689"),
+			expectedCode: "689",
+		},
+	}
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			code := GerritErrCode(test.inputErr)
+			if code != test.expectedCode {
+				t.Errorf("GerritErrCode failed, expected HTTP code %s, got %s", test.expectedCode, code)
+			}
+		})
+	}
+}
+
+func TestFindBuildError(t *testing.T) {
+	tests := map[string]struct {
+		inputCode      string
+		clID           string
+		expectedCode   string
+		expectedStatus string
+		expectedErrStr string
+	}{
+		"No Code": {
+			inputCode:      "500",
+			clID:           "1",
+			expectedCode:   "500",
+			expectedStatus: "500 Internal Server Error",
+			expectedErrStr: "An unexpected error occurred while retrieving the requested information.",
+		},
+		"Unformatted Error": {
+			inputCode:      "403",
+			clID:           "1",
+			expectedCode:   "403",
+			expectedStatus: "403 Forbidden",
+			expectedErrStr: "This account does not have access to internal repositories. Please retry with an authorized account, or select the external button to query from publically accessible builds.",
+		},
+		"Formatted Error": {
+			inputCode:      "404",
+			clID:           "1214",
+			expectedCode:   "404",
+			expectedStatus: "404 Not Found",
+			expectedErrStr: "No build found for a CL with ID 1214. Please enter either the CL-Number (example: 3206) or a Commit-SHA (example: I7e549d7753cc7acec2b44bb5a305347a97719ab9) of a submitted CL.",
+		},
+		"Unmapped Code": {
+			inputCode:      "500",
+			clID:           "1",
+			expectedCode:   "500",
+			expectedStatus: "500 Internal Server Error",
+			expectedErrStr: "An unexpected error occurred while retrieving the requested information.",
+		},
+	}
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			err := FromFindBuildError(test.inputCode, test.clID)
+			if err.HTTPCode() != test.expectedCode {
+				t.Errorf("FromFindBuildError failed, expected HTTP code %s, got %s", test.expectedCode, err.HTTPCode())
+			} else if err.HTTPStatus() != test.expectedStatus {
+				t.Errorf("FromFindBuildError failed, expected HTTP status %s, got %s", test.expectedStatus, err.HTTPStatus())
+			} else if err.Error() != test.expectedErrStr {
+				t.Errorf("FromFindBuildError failed, expected error string %s, got %s", test.expectedErrStr, err.Error())
+			}
+		})
+	}
+}
+
+func TestGitilesErrCode(t *testing.T) {
+	tests := map[string]struct {
+		inputErr     error
+		expectedCode string
+	}{
+		"Default Error Code": {
+			inputErr:     errors.New("code = e desc = not a desc"),
+			expectedCode: "500",
+		},
+		"Mapped Error Code": {
+			inputErr:     status.New(codes.NotFound, "not found").Err(),
+			expectedCode: "404",
+		},
+		"403 Code Edge Case": {
+			inputErr:     status.New(codes.Internal, "unexpected HTTP 403 from Gitiles").Err(),
+			expectedCode: "403",
+		},
+	}
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			code := GitilesErrCode(test.inputErr)
+			if code != test.expectedCode {
+				t.Errorf("GitilesErrCode failed, expected HTTP code %s, got %s", test.expectedCode, code)
+			}
+		})
+	}
+}
+
+func TestFromChangelogError(t *testing.T) {
+	tests := map[string]struct {
+		inputCode      string
+		clID           string
+		expectedCode   string
+		expectedStatus string
+		expectedErrStr string
+	}{
+		"No Code": {
+			inputCode:      "500",
+			clID:           "1",
+			expectedCode:   "500",
+			expectedStatus: "500 Internal Server Error",
+			expectedErrStr: "An unexpected error occurred while retrieving the requested information.",
+		},
+		"Unformatted Error": {
+			inputCode:      "403",
+			clID:           "1",
+			expectedCode:   "403",
+			expectedStatus: "403 Forbidden",
+			expectedErrStr: "This account does not have access to internal repositories. Please retry with an authorized account, or select the external button to query from publically accessible builds.",
+		},
+		"Formatted Error": {
+			inputCode:      "404",
+			clID:           "15000.1.0",
+			expectedCode:   "404",
+			expectedStatus: "404 Not Found",
+			expectedErrStr: "Build number 15000.1.0 not found. Please input a valid build number (example: 13310.1035.0).",
+		},
+		"Unmapped Code": {
+			inputCode:      "500",
+			clID:           "1",
+			expectedCode:   "500",
+			expectedStatus: "500 Internal Server Error",
+			expectedErrStr: "An unexpected error occurred while retrieving the requested information.",
+		},
+	}
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			err := FromChangelogError(test.inputCode, test.clID)
+			if err.HTTPCode() != test.expectedCode {
+				t.Errorf("FromChangelogError failed, expected HTTP code %s, got %s", test.expectedCode, err.HTTPCode())
+			} else if err.HTTPStatus() != test.expectedStatus {
+				t.Errorf("FromChangelogError failed, expected HTTP status %s, got %s", test.expectedStatus, err.HTTPStatus())
+			} else if err.Error() != test.expectedErrStr {
+				t.Errorf("FromChangelogError failed, expected error string %s, got %s", test.expectedErrStr, err.Error())
+			}
+		})
+	}
+}