func stateFilter(query url.Values) (change.StateFilter, error) {
	selectedTabName := query.Get(stateQueryKey)
	switch selectedTabName {
	case "":
		return change.FilterOpen, nil
	case "closed":
		return change.FilterClosedMerged, nil
	case "all":
		return change.FilterAll, nil
	default:
		return "", fmt.Errorf("unsupported state filter value: %q", selectedTabName)
	}
}
stateFilter parses the change state filter from query,
returning an error if the value is unsupported.
package changesapp
import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"html/template"
	"io"
	"log"
	"net/http"
	"net/url"
	"os"
	"sort"
	"strconv"
	"strings"
	"time"
	"github.com/dustin/go-humanize"
	"github.com/shurcooL/github_flavored_markdown"
	homecomponent "github.com/shurcooL/home/component"
	"github.com/shurcooL/home/internal/exp/app/changesapp/component"
	"github.com/shurcooL/home/internal/exp/service/change"
	"github.com/shurcooL/home/internal/exp/service/notification"
	"github.com/shurcooL/htmlg"
	"github.com/shurcooL/httperror"
	"github.com/shurcooL/octicon"
	"github.com/shurcooL/reactions"
	reactionscomponent "github.com/shurcooL/reactions/component"
	"github.com/shurcooL/users"
	"github.com/sourcegraph/go-diff/diff"
	"golang.org/x/net/html"
	"golang.org/x/sync/errgroup"
)
func New(cs change.Service, us users.Service, redirect func(*url.URL), opt Options) *app {
	return &app{
		cs:       cs,
		us:       us,
		redirect: redirect,
		opt:      opt,
	}
}
type Options struct {
	Notification notification.Service
	BodyTop func(context.Context, State) ([]htmlg.Component, error)
}
type State struct {
	ReqURL      *url.URL
	CurrentUser users.User
	RepoSpec    string
	BaseURL     string  
	ChangeID uint64  
	PrevSHA string  
	NextSHA string  
}
func (s State) RequestURL() *url.URL { return s.ReqURL }
type app struct {
	cs change.Service
	us users.Service
	redirect func(*url.URL)  
	opt      Options
}
func (a *app) ServePage(ctx context.Context, w io.Writer, reqURL *url.URL) (interface{}, error) {
	authenticatedUser, err := a.us.GetAuthenticated(ctx)
	if err != nil {
		log.Println(err)
		authenticatedUser = users.User{}  
	}
	repoSpec, baseURL, route, err := parseRequest(reqURL, authenticatedUser.UserSpec)
	if err != nil {
		return nil, err
	}
	st := State{
		ReqURL:      reqURL,
		CurrentUser: authenticatedUser,
		RepoSpec:    repoSpec,
		BaseURL:     baseURL,
	}
	_, err = io.WriteString(w, `<link href="/assets/changes/style.css" rel="stylesheet">`)
	if err != nil {
		return nil, err
	}
	if route == "/" {
		return st, a.serveChanges(ctx, w, st)
	}
	elems := strings.SplitN(route[1:], "/", 3)
	st.ChangeID, err = strconv.ParseUint(elems[0], 10, 64)
	if err != nil {
		return nil, httperror.HTTP{Code: http.StatusNotFound, Err: fmt.Errorf("invalid change ID %q: %v", elems[0], err)}
	}
	switch {
	case len(elems) == 1:
		return st, a.serveChange(ctx, w, st)
	case len(elems) == 2 && elems[1] == "commits":
		return st, a.serveChangeCommits(ctx, w, st)
	case len(elems) == 2 && elems[1] == "files":
		_, _, err := a.serveChangeFiles(ctx, w, st, "")
		return st, err
	case len(elems) == 3 && elems[1] == "files":
		commitID := elems[2]
		var err error
		st.PrevSHA, st.NextSHA, err = a.serveChangeFiles(ctx, w, st, commitID)
		return st, err
	default:
		return nil, httperror.HTTP{Code: http.StatusNotFound, Err: errors.New("no route")}
	}
}
func parseRequest(reqURL *url.URL, currentUser users.UserSpec) (repoSpec, baseURL, route string, err error) {
	switch i := strings.Index(reqURL.Path, "/...$changes"); {
	case i >= 0:
		repoSpec = "dmitri.shuralyov.com" + reqURL.Path[:i]
		baseURL = reqURL.Path[:i+len("/...$changes")]
		route = reqURL.Path[i+len("/...$changes"):]
	case strings.HasPrefix(reqURL.Path, "/changes/github.com/"):
		elems := strings.SplitN(reqURL.Path[len("/changes/github.com/"):], "/", 3)
		if len(elems) < 2 || elems[0] == "" || elems[1] == "" {
			return "", "", "", os.ErrNotExist
		}
		if currentUser != dmitshur {
			switch len(elems) {
			case 2:
				return "", "", "", httperror.Redirect{URL: "https://github.com/" + elems[0] + "/" + elems[1] + "/pulls"}
			default:  
				return "", "", "", httperror.Redirect{URL: "https://github.com/" + elems[0] + "/" + elems[1] + "/pull/" + elems[2]}
			}
		}
		repoSpec = "github.com/" + elems[0] + "/" + elems[1]
		baseURL = "/changes/" + repoSpec
		route = reqURL.Path[len(baseURL):]
	case strings.HasPrefix(reqURL.Path, "/changes/go.googlesource.com/"):
		elems := strings.SplitN(reqURL.Path[len("/changes/go.googlesource.com/"):], "/", 2)
		if len(elems) < 1 || elems[0] == "" {
			return "", "", "", os.ErrNotExist
		}
		if currentUser != dmitshur {
			switch len(elems) {
			case 1:
				return "", "", "", httperror.Redirect{URL: fmt.Sprintf("https://go-review.googlesource.com/q/project:%s+status:open", elems[0])}
			default:  
				return "", "", "", httperror.Redirect{URL: fmt.Sprintf("https://go-review.googlesource.com/c/%s/+/%s", elems[0], elems[1])}
			}
		}
		repoSpec = "go.googlesource.com/" + elems[0]
		baseURL = "/changes/" + repoSpec
		route = reqURL.Path[len(baseURL):]
	default:
		return "", "", "", os.ErrNotExist
	}
	switch route {
	case "/":
		if q := reqURL.RawQuery; q != "" {
			baseURL += "?" + q
		}
		return "", "", "", httperror.Redirect{URL: baseURL}
	case "":
		route = "/"
	}
	return repoSpec, baseURL, route, nil
}
var dmitshur = users.UserSpec{ID: 1924134, Domain: "github.com"}
func (a *app) serveChanges(ctx context.Context, w io.Writer, st State) error {
	filter, err := stateFilter(st.ReqURL.Query())
	if err != nil {
		return httperror.BadRequest{Err: err}
	}
	var (
		bodyTop                template.HTML
		cs                     []change.Change
		openCount, closedCount uint64
	)
	g, groupContext := errgroup.WithContext(ctx)
	g.Go(func() error {
		var err error
		bodyTop, err = a.bodyTop(groupContext, st)
		if err != nil {
			return fmt.Errorf("bodyTop: %w", err)
		}
		return nil
	})
	g.Go(func() error {
		var err error
		cs, err = a.cs.List(groupContext, st.RepoSpec, change.ListOptions{Filter: filter})
		if err != nil {
			return fmt.Errorf("change.List: %w", err)
		}
		return nil
	})
	g.Go(func() error {
		var err error
		openCount, err = a.cs.Count(groupContext, st.RepoSpec, change.ListOptions{Filter: change.FilterOpen})
		if err != nil {
			return fmt.Errorf("change.Count(open): %w", err)
		}
		return nil
	})
	g.Go(func() error {
		var err error
		closedCount, err = a.cs.Count(groupContext, st.RepoSpec, change.ListOptions{Filter: change.FilterClosedMerged})
		if err != nil {
			return fmt.Errorf("change.Count(closed): %w", err)
		}
		return nil
	})
	err = g.Wait()
	if err != nil {
		return err
	}
	var es []component.ChangeEntry
	for _, c := range cs {
		es = append(es, component.ChangeEntry{Change: c, BaseURL: st.BaseURL})
	}
	es = a.augmentUnread(ctx, st, es)
	changes := component.Changes{
		ChangesNav: component.ChangesNav{
			OpenCount:     openCount,
			ClosedCount:   closedCount,
			Path:          st.ReqURL.Path,
			Query:         st.ReqURL.Query(),
			StateQueryKey: stateQueryKey,
		},
		Filter:  filter,
		Entries: es,
	}
	tt, err := t.Clone()
	if err != nil {
		return fmt.Errorf("t.Clone: %v", err)
	}
	err = tt.ExecuteTemplate(w, "changes.html.tmpl", renderState{
		BodyPre: bodyPre, BodyPost: bodyPost,
		BodyTop:     bodyTop,
		BaseURL:     st.BaseURL,
		CurrentUser: st.CurrentUser,
		Changes:     changes,
	})
	return err
}
func (a *app) augmentUnread(ctx context.Context, st State, es []component.ChangeEntry) []component.ChangeEntry {
	if a.opt.Notification == nil {
		return es
	}
	if st.CurrentUser.ID == 0 {
		return es
	}
	threadType, err := a.cs.ThreadType(ctx, st.RepoSpec)
	if err != nil {
		log.Println("augmentUnread: failed to notifications.ThreadType:", err)
		return es
	}
	ns, err := a.opt.Notification.ListNotifications(ctx, notification.ListOptions{
		Namespace: st.RepoSpec,
	})
	if err != nil {
		log.Println("augmentUnread: failed to notifications.List:", err)
		return es
	}
	unreadThreads := make(map[uint64]struct{})  
	for _, n := range ns {
		if n.ThreadType != threadType {
			continue
		}
		unreadThreads[n.ThreadID] = struct{}{}
	}
	for i, e := range es {
		_, unread := unreadThreads[e.Change.ID]
		es[i].Unread = unread
	}
	return es
}
func (a *app) bodyTop(ctx context.Context, st State) (template.HTML, error) {
	if a.opt.BodyTop == nil {
		return "", nil
	}
	c, err := a.opt.BodyTop(ctx, st)
	if err != nil {
		return "", err
	}
	var buf bytes.Buffer
	err = htmlg.RenderComponents(&buf, c...)
	if err != nil {
		return "", fmt.Errorf("htmlg.RenderComponents: %v", err)
	}
	return template.HTML(buf.String()), nil
}
func (a *app) serveChange(ctx context.Context, w io.Writer, st State) error {
	bodyTop, err := a.bodyTop(ctx, st)
	if err != nil {
		return fmt.Errorf("bodyTop: %w", err)
	}
	c, err := a.cs.Get(ctx, st.RepoSpec, st.ChangeID)
	if err != nil {
		return err
	}
	ts, err := a.cs.ListTimeline(ctx, st.RepoSpec, st.ChangeID, nil)
	if err != nil {
		return fmt.Errorf("change.ListTimeline: %w", err)
	}
	if a.opt.Notification != nil {
		err := a.markRead(ctx, st)
		if err != nil {
			log.Println("serveChange: failed to markRead:", err)
		}
	}
	var timeline []timelineItem
	for _, item := range ts {
		timeline = append(timeline, timelineItem{item})
	}
	sort.Sort(byCreatedAtID(timeline))
	tt, err := t.Clone()
	if err != nil {
		return fmt.Errorf("t.Clone: %v", err)
	}
	tt, err = tt.Funcs(template.FuncMap{
		"reactableID": func(commentID string) string {
			return fmt.Sprintf("%d/%s", st.ChangeID, commentID)
		},
		"reactionsBar": func(reactions []reactions.Reaction, reactableID string) htmlg.Component {
			return reactionscomponent.ReactionsBar{
				Reactions:   reactions,
				CurrentUser: st.CurrentUser,
				ID:          reactableID,
			}
		},
		"event": func(e change.TimelineItem) htmlg.Component {
			return component.Event{Event: e, BaseURL: st.BaseURL, ChangeID: st.ChangeID}
		},
	}).Parse(`
{{ }}
{{define "comment"}}
<div class="list-entry">
	<div style="float: left; margin-right: 10px;">{{render (avatar .User)}}</div>
	<div style="display: flex; flex-direction: column;">
		<div id="comment-{{.ID}}">
			<div class="list-entry-container list-entry-border">
				<header class="list-entry-header" style="display: flex;">
					<span class="content">{{render (user .User)}} commented <a class="black" href="#comment-{{.ID}}" onclick="AnchorScroll(this, event);">{{render (time .CreatedAt)}}</a>
						{{with .Edited}} · <span style="cursor: default;" title="{{.By.Login}} edited this comment {{reltime .At}}.">edited{{if not (equalUsers $.User .By)}} by {{.By.Login}}{{end}}</span>{{end}}
					</span>
					<span class="right-icon">{{render (newReaction (reactableID .ID))}}</span>
					{{if .Editable}}<span class="right-icon"><a href="javascript:" title="Edit" onclick="EditComment({{` + "`edit`" + ` | json}}, this);">{{octicon "pencil"}}</a></span>{{end}}
				</header>
				<div class="list-entry-body">
					<div class="markdown-body">
						{{with .Body}}
							{{. | gfm}}
						{{else}}
							<i class="gray">No description.</i>
						{{end}}
					</div>
				</div>
			</div>
		</div>
		{{render (reactionsBar .Reactions (reactableID .ID))}}
	</div>
</div>
{{end}}
{{ }}
{{define "review"}}
<div class="list-entry">
	<div style="float: left; margin-right: 10px;">{{render (avatar .User)}}</div>
	<div style="display: flex; flex-direction: column;">
		<div id="comment-{{.ID}}">
			<div class="list-entry-container list-entry-border">
				<header class="list-entry-header" style="display: flex;{{if ne .State 0}} padding: 4px;{{end}}{{if not .Body}} border: none;{{end}}">
					<span class="content"{{if .State}} style="line-height: 28px;"{{end}}>{{template "review-icon" .State}}{{render (user .User)}} {{template "review-action" .State}} <a class="black" href="#comment-{{.ID}}" onclick="AnchorScroll(this, event);">{{render (time .CreatedAt)}}</a>
						{{with .Edited}} · <span style="cursor: default;" title="{{.By.Login}} edited this comment {{reltime .At}}.">edited{{if not (equalUsers $.User .By)}} by {{.By.Login}}{{end}}</span>{{end}}
					</span>
					<span class="right-icon">{{render (newReaction (reactableID .ID))}}</span>
					{{if .Editable}}<span class="right-icon"><a href="javascript:" title="Edit" onclick="EditComment({{` + "`edit`" + ` | json}}, this);">{{octicon "pencil"}}</a></span>{{end}}
				</header>
				{{with .Body}}
				<div class="list-entry-body">
					<div class="markdown-body">
						{{. | gfm}}
					</div>
				</div>
				{{end}}
			</div>
		</div>
		{{render (reactionsBar .Reactions (reactableID .ID))}}
	</div>
	{{with .Comments}}
		<div style="margin-left: 80px;">
		{{range .}}
			<div class="list-entry list-entry-container list-entry-border">
				<header class="list-entry-header">
					<span class="content">{{.File}}:{{.Line}}</span>
					<span class="right-icon">{{render (newReaction (reactableID .ID))}}</span>
				</header>
				<div class="list-entry-body">
					<div class="markdown-body">{{.Body | gfm}}</div>
				</div>
			</div>
			{{render (reactionsBar .Reactions (reactableID .ID))}}
		{{end}}
		</div>
	{{end}}
</div>
{{end}}
`)
	if err != nil {
		return err
	}
	err = tt.ExecuteTemplate(w, "change.html.tmpl", renderState{
		BodyPre: bodyPre, BodyPost: bodyPost,
		BodyTop:     bodyTop,
		BaseURL:     st.BaseURL,
		CurrentUser: st.CurrentUser,
		ChangeID:    st.ChangeID,
		Change:      c,
		Timeline:    timeline,
	})
	return err
}
func (a *app) markRead(ctx context.Context, st State) error {
	threadType, err := a.cs.ThreadType(ctx, st.RepoSpec)
	if err != nil {
		return err
	}
	if st.CurrentUser.UserSpec == (users.UserSpec{}) {
		return nil
	}
	err = a.opt.Notification.MarkThreadRead(ctx, st.RepoSpec, threadType, st.ChangeID)
	return err
}
func (a *app) serveChangeCommits(ctx context.Context, w io.Writer, st State) error {
	bodyTop, err := a.bodyTop(ctx, st)
	if err != nil {
		return fmt.Errorf("bodyTop: %w", err)
	}
	c, err := a.cs.Get(ctx, st.RepoSpec, st.ChangeID)
	if err != nil {
		return err
	}
	list, err := a.cs.ListCommits(ctx, st.RepoSpec, st.ChangeID)
	if err != nil {
		return err
	}
	tt, err := t.Clone()
	if err != nil {
		return fmt.Errorf("t.Clone: %v", err)
	}
	err = tt.ExecuteTemplate(w, "change-commits.html.tmpl", renderState{
		BodyPre: bodyPre, BodyPost: bodyPost,
		BodyTop:     bodyTop,
		BaseURL:     st.BaseURL,
		CurrentUser: st.CurrentUser,
		ChangeID:    st.ChangeID,
		Change:      c,
	})
	if err != nil {
		return err
	}
	var cs []commit
	for _, c := range list {
		cs = append(cs, commit{Commit: c})
	}
	err = htmlg.RenderComponents(w, commits{Commits: cs})
	if err != nil {
		return err
	}
	_, err = io.WriteString(w, bodyPost)
	return err
}
func (a *app) serveChangeFiles(ctx context.Context, w io.Writer, st State, commitID string) (prevSHA, nextSHA string, _ error) {
	bodyTop, err := a.bodyTop(ctx, st)
	if err != nil {
		return "", "", fmt.Errorf("bodyTop: %w", err)
	}
	c, err := a.cs.Get(ctx, st.RepoSpec, st.ChangeID)
	if err != nil {
		return "", "", err
	}
	var commit commitMessage
	if commitID != "" {
		cs, err := a.cs.ListCommits(ctx, st.RepoSpec, st.ChangeID)
		if err != nil {
			return "", "", err
		}
		i := commitIndex(cs, commitID)
		if i == -1 {
			return "", "", os.ErrNotExist
		}
		subject, body := splitCommitMessage(cs[i].Message)
		commit = commitMessage{
			CommitHash: cs[i].SHA,
			Subject:    subject,
			Body:       body,
			Author:     cs[i].Author,
			AuthorTime: cs[i].AuthorTime,
		}
		if prev := i - 1; prev >= 0 {
			commit.PrevSHA = cs[prev].SHA
		}
		if next := i + 1; next < len(cs) {
			commit.NextSHA = cs[next].SHA
		}
	}
	var opt *change.GetDiffOptions
	if commitID != "" {
		opt = &change.GetDiffOptions{Commit: commitID}
	}
	rawDiff, err := a.cs.GetDiff(ctx, st.RepoSpec, st.ChangeID, opt)
	if err != nil {
		return "", "", err
	}
	fileDiffs, err := diff.ParseMultiFileDiff(rawDiff)
	if err != nil {
		return "", "", err
	}
	tt, err := t.Clone()
	if err != nil {
		return "", "", fmt.Errorf("t.Clone: %v", err)
	}
	err = tt.ExecuteTemplate(w, "change-files.html.tmpl", renderState{
		BodyPre: bodyPre, BodyPost: bodyPost,
		BodyTop:     bodyTop,
		BaseURL:     st.BaseURL,
		CurrentUser: st.CurrentUser,
		ChangeID:    st.ChangeID,
		Change:      c,
	})
	if err != nil {
		return "", "", err
	}
	if commitID != "" {
		err := tt.ExecuteTemplate(w, "CommitMessage", commit)
		if err != nil {
			return "", "", err
		}
	}
	for _, f := range fileDiffs {
		err := tt.ExecuteTemplate(w, "FileDiff", fileDiff{FileDiff: f})
		if err != nil {
			return "", "", err
		}
	}
	_, err = io.WriteString(w, bodyPost)
	if err != nil {
		return "", "", err
	}
	return commit.PrevSHA, commit.NextSHA, nil
}
func commitIndex(cs []change.Commit, commitID string) int {
	for i := range cs {
		if cs[i].SHA == commitID {
			return i
		}
	}
	return -1
}
const (
	stateQueryKey = "state"
)