dmitri.shuralyov.com/website/gido/...

Add support for viewing CLs/PRs of Go packages via gochanges.org.

It's sometimes desirable to see all CLs/PRs for a specific Go package.
This change makes that possible via the https://gochanges.org domain.
dmitshur committed 4 years ago commit 38606cedcd3a0a0c8a9ea329973e40c85a003fef
_data/style.css
@@ -81,28 +81,21 @@ div.list-entry-container {
}
div.list-entry-border {
	border: 1px solid #ddd;
	border-radius: 4px;
}
div.list-entry-header {
.list-entry-header {
	font-size: 13px;
	background-color: #f8f8f8;
	padding: 10px;
	border-radius: 4px 4px 0 0;
	border-bottom: 1px solid #eee;
}
.hash-selected div.list-entry-border {
	border: 1px solid #8ca2d9;
}
.hash-selected div.list-entry-header {
	background-color: #dbe5ff;
	border-bottom: 1px solid #8ca2d9;
}
div.list-entry-header.tabs {
.list-entry-header.tabs {
	padding: 16px 10px 6px 10px;
}
div.list-entry-header.tabs-title {
.list-entry-header.tabs-title {
	padding-bottom: 6px;
	background-color: #fff;
}
div.list-entry-body {
	padding: 10px;
@@ -111,18 +104,21 @@ div.list-entry-body {
div.multilist-entry:not(:nth-child(0n+2)) {
	border: 0px solid #ddd;
	border-top-width: 1px;
}

div.list-entry-header nav a {
.list-entry-header nav a {
	color: #767676;
	text-decoration: none;
}
div.list-entry-header nav a:hover,
div.list-entry-header nav a:active,
div.list-entry-header nav a:focus {
.list-entry-header nav a:hover,
.list-entry-header nav a:active,
.list-entry-header nav a:focus {
	color: #000;
}
div.list-entry-header nav .selected {
.list-entry-header nav .selected {
	color: #000;
	font-weight: bold;
}

/* https://github.com/primer/primer-navigation */
.counter{display:inline-block;padding:2px 5px;font-size:12px;font-weight:600;line-height:1;color:#666;background-color:#eee;border-radius:20px}.menu{margin-bottom:15px;list-style:none;background-color:#fff;border:1px solid #d8d8d8;border-radius:3px}.menu-item{position:relative;display:block;padding:8px 10px;border-bottom:1px solid #eee}.menu-item:first-child{border-top:0;border-top-left-radius:2px;border-top-right-radius:2px}.menu-item:first-child::before{border-top-left-radius:2px}.menu-item:last-child{border-bottom:0;border-bottom-right-radius:2px;border-bottom-left-radius:2px}.menu-item:last-child::before{border-bottom-left-radius:2px}.menu-item:hover{text-decoration:none;background-color:#f9f9f9}.menu-item.selected{font-weight:bold;color:#222;cursor:default;background-color:#fff}.menu-item.selected::before{position:absolute;top:0;bottom:0;left:0;width:2px;content:"";background-color:#d26911}.menu-item .octicon{width:16px;margin-right:5px;color:#333;text-align:center}.menu-item .counter{float:right;margin-left:5px}.menu-item .menu-warning{float:right;color:#d26911}.menu-item .avatar{float:left;margin-right:5px}.menu-item.alert .counter{color:#bd2c00}.menu-heading{display:block;padding:8px 10px;margin-top:0;margin-bottom:0;font-size:13px;font-weight:bold;line-height:20px;color:#555;background-color:#f7f7f7;border-bottom:1px solid #eee}.menu-heading:hover{text-decoration:none}.menu-heading:first-child{border-top-left-radius:2px;border-top-right-radius:2px}.menu-heading:last-child{border-bottom:0;border-bottom-right-radius:2px;border-bottom-left-radius:2px}.tabnav{margin-top:0;margin-bottom:15px;border-bottom:1px solid #ddd}.tabnav .counter{margin-left:5px}.tabnav-tabs{margin-bottom:-1px}.tabnav-tab{display:inline-block;padding:8px 12px;font-size:14px;line-height:20px;color:#666;text-decoration:none;background-color:transparent;border:1px solid transparent;border-bottom:0}.tabnav-tab.selected{color:#333;background-color:#fff;border-color:#ddd;border-radius:3px 3px 0 0}.tabnav-tab:hover,.tabnav-tab:focus{text-decoration:none}.tabnav-extra{display:inline-block;padding-top:10px;margin-left:10px;font-size:12px;color:#666}.tabnav-extra>.octicon{margin-right:2px}a.tabnav-extra:hover{color:#4078c0;text-decoration:none}.tabnav-btn{margin-left:10px}.filter-list{list-style-type:none}.filter-list.small .filter-item{padding:4px 10px;margin:0 0 2px;font-size:12px}.filter-list.pjax-active .filter-item{color:#767676;background-color:transparent}.filter-list.pjax-active .filter-item.pjax-active{color:#fff;background-color:#4078c0}.filter-item{position:relative;display:block;padding:8px 10px;margin-bottom:5px;overflow:hidden;font-size:14px;color:#767676;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;border-radius:3px}.filter-item:hover{text-decoration:none;background-color:#eee}.filter-item.selected{color:#fff;background-color:#4078c0}.filter-item .count{float:right;font-weight:bold}.filter-item .bar{position:absolute;top:2px;right:0;bottom:2px;z-index:-1;display:inline-block;background-color:#f1f1f1}.subnav{margin-bottom:20px}.subnav::before{display:table;content:""}.subnav::after{display:table;clear:both;content:""}.subnav-bordered{padding-bottom:20px;border-bottom:1px solid #eee}.subnav-flush{margin-bottom:0}.subnav-item{position:relative;float:left;padding:6px 14px;font-weight:600;line-height:20px;color:#666;border:1px solid #e5e5e5}.subnav-item+.subnav-item{margin-left:-1px}.subnav-item:hover,.subnav-item:focus{text-decoration:none;background-color:#f5f5f5}.subnav-item.selected,.subnav-item.selected:hover,.subnav-item.selected:focus{z-index:2;color:#fff;background-color:#4078c0;border-color:#4078c0}.subnav-item:first-child{border-top-left-radius:3px;border-bottom-left-radius:3px}.subnav-item:last-child{border-top-right-radius:3px;border-bottom-right-radius:3px}.subnav-search{position:relative;margin-left:10px}.subnav-search-input{width:320px;padding-left:30px;color:#767676;border-color:#d5d5d5}.subnav-search-input-wide{width:500px}.subnav-search-icon{position:absolute;top:9px;left:8px;display:block;color:#ccc;text-align:center;pointer-events:none}.subnav-search-context .btn{color:#555;border-top-right-radius:0;border-bottom-right-radius:0}.subnav-search-context .btn:hover,.subnav-search-context .btn:focus,.subnav-search-context .btn:active,.subnav-search-context .btn.selected{z-index:2}.subnav-search-context+.subnav-search{margin-left:-1px}.subnav-search-context+.subnav-search .subnav-search-input{border-top-left-radius:0;border-bottom-left-radius:0}.subnav-search-context .select-menu-modal-holder{z-index:30}.subnav-search-context .select-menu-modal{width:220px}.subnav-search-context .select-menu-item-icon{color:inherit}.subnav-spacer-right{padding-right:10px}
assets/assets_vfsdata.go
@@ -27,14 +27,14 @@ var Assets = func() http.FileSystem {
			name:    "assets",
			modTime: time.Date(2018, 3, 14, 5, 17, 43, 661733693, time.UTC),
		},
		"/assets/style.css": &vfsgen۰CompressedFileInfo{
			name:             "style.css",
			modTime:          time.Date(2018, 3, 14, 5, 17, 43, 662237105, time.UTC),
			uncompressedSize: 2137,
			modTime:          time.Date(2018, 4, 9, 19, 18, 11, 551203092, time.UTC),
			uncompressedSize: 6759,

			compressedContent: []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x94\x55\xeb\x6e\xa3\x3a\x10\xfe\x1d\x9e\xc2\x6a\x75\xa4\x56\xa7\x44\x90\x5b\xa9\x79\x80\xfe\x3a\x0f\x61\x6c\x13\xac\x3a\x36\x32\x93\x94\x9c\xd5\xbe\xfb\xca\x17\x6e\x09\x61\x77\x83\x50\x80\x19\x7f\x33\xf3\x7d\x33\x76\xa1\xd9\x15\xfd\x88\x56\xa5\x56\x10\x97\xe4\x24\xe4\x15\xa3\x4f\x9d\x87\x2f\x8d\xf8\x9f\x63\x94\xee\xea\x36\x8f\x56\x54\x4b\x6d\x30\x32\xc7\xe2\x65\xbb\x7f\x43\xfe\x7e\xcd\xa3\x9f\x11\xb1\x10\xc1\xfc\xbc\x4b\xb3\x2d\xdd\xe5\xd1\x0a\x78\x0b\x31\xe3\x54\x1b\x02\x42\x2b\x8c\x94\x56\xdc\xb9\xe3\x4a\x5f\xb8\xb1\x8b\xee\x7c\xce\x8a\x71\x23\x45\x70\x5c\x17\x92\xd0\xaf\x11\xba\x7b\x1f\x99\x06\xa4\x3f\x0f\x1f\x51\xcd\xf8\x5d\xd1\x4f\x9f\x1a\xfd\xa7\x95\x7e\xb2\x2e\x5f\x05\xb3\x0e\x4c\x34\xb5\x24\x57\x8c\x84\xb2\x29\xc5\x85\xd4\x36\xfa\xaa\x26\x8c\x09\x75\xc4\x68\x5b\xb7\x68\x5f\xb7\x68\xe3\xff\xf3\xc7\x98\x13\x3e\x37\xce\xd5\x41\x56\x5c\x1c\x2b\xc0\x28\x4d\xdc\xb7\x82\xd0\xaf\xa3\xd1\x67\xc5\xe2\xae\xa0\x92\x94\x45\x49\xad\x4d\x1b\xc6\x0d\x46\x8d\x96\x82\xa1\xb4\x6e\xd1\x33\x3d\xd0\x82\xa5\xbd\x2d\x2e\x34\x80\x3e\xc5\xdf\x82\x41\x85\x91\x0f\x33\x35\x75\xa8\x1f\xfb\x0f\x46\xf6\x83\xd9\x10\x26\xce\x8d\xab\xc8\x71\xb4\x06\x52\xc8\x81\xa5\x49\x23\x84\x25\x54\x4b\x49\xea\x86\x63\xd4\x3d\xcd\x16\x00\x86\xa8\xa6\x26\x86\x2b\xc8\xa3\x55\x48\x2d\x4d\x92\x7f\xf2\x68\x75\x22\x6d\x7c\xfb\xc5\x1c\x85\x0a\xd9\x62\xb4\x49\x7c\x3e\x21\x1d\xa8\xde\x50\xf7\xe8\x14\xea\x95\xc8\x66\x28\x5d\xef\x36\xd9\xfe\x3d\xdd\x6d\xf3\x68\x75\xe1\x06\x04\x25\x32\x26\x52\x1c\x15\x46\xa0\xeb\x31\xae\x03\x0b\x75\x81\xae\xb1\xa3\xd7\x13\xfd\xcc\xdc\x6f\x92\x05\x27\x0c\x41\xd5\x37\x70\xc0\x94\xbc\x84\x99\x50\xbe\x96\x5b\x25\x9c\x3c\x0f\x43\x0c\xf4\xcf\x75\x44\x59\x0e\xae\x71\x73\x1a\xd1\xe2\xde\xa6\xcc\xec\x3b\x45\x8f\x86\x5c\xc7\x93\x92\x65\x99\x83\x91\x96\xae\x5b\x63\x9f\x8e\x50\xd7\xdb\x2e\xd8\x04\x44\x26\x2e\x6b\x29\x1a\x88\xb9\x02\xe3\xbc\x82\x7a\x9e\xc1\xe0\x36\xf5\x8a\xa9\x56\x40\x84\xf2\x43\x3b\xd5\xbe\xd0\xad\x0d\xe1\xb2\xee\xb9\x9a\xc3\xf0\xc6\x41\xb2\x5b\xb9\xee\xdb\x7a\x37\x9b\x8b\xd5\xd1\xe3\x8c\xcb\xdb\x3e\x9c\xc4\xcc\x5e\xe3\xf9\xef\xa6\xf6\x2e\x98\xbb\x13\x94\xdc\xab\x3e\xca\x94\x73\xb7\x1f\xad\x2b\xd2\x54\x71\xc3\x25\xa7\xc0\x19\xfa\x9b\x5a\x33\x4a\x36\xec\xe3\xf7\x20\x43\xa1\x33\x65\xb1\x82\xef\x6d\x47\x2d\x64\x3a\xc4\x99\x05\xb6\xbd\xd7\x4c\x9a\x2e\x3d\xd4\xad\x63\x07\x75\x0f\xcb\x8b\x63\x10\xe0\xfb\x3d\x40\xf4\x59\x1c\x1e\xaa\xe1\xa7\xe0\x8e\x2e\x7f\xa0\xdd\x48\x14\xba\xf5\x74\x96\x20\x06\x6f\xac\x34\xbc\x60\x05\x55\x4c\x2b\x21\xd9\x4b\xa2\xfe\xdd\xbc\xbe\x8e\xb9\x4e\x1e\xf4\x15\xe8\xba\xdf\xb8\x66\xa7\xa1\xa3\x5c\x91\x0b\x9a\x1c\x8f\xef\x07\x7b\x2d\x9d\x4f\x0b\x40\xfe\xb8\x7b\x5b\x74\x21\x14\xc4\x85\x2f\xfb\x94\x9a\x9e\x9b\x71\x56\x49\x92\x2c\x87\x5e\xf7\xad\x75\xbb\xca\xcf\xce\x77\xd8\x74\x0b\x2d\xdd\xc6\xf1\x2b\x00\x00\xff\xff\xda\x4e\x50\x1d\x59\x08\x00\x00"),
			compressedContent: []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xb4\x58\xd9\x6e\xe3\x3a\x12\x7d\x8e\xbf\x82\xe8\x60\x80\xee\xdb\x91\x23\xcb\x4b\x1c\x0a\x98\xd7\xfb\x34\x1f\x41\x89\x94\xc5\x69\x9a\x14\x28\xda\x71\xda\xc8\xbf\x0f\xb8\x48\x22\xa9\x25\xe9\x01\x6e\x84\x20\x8e\x58\x3c\x55\xac\x3a\xb5\xd0\x85\xc0\xef\xe0\xbe\x7a\xa8\x04\x57\x49\x85\xce\x94\xbd\x43\xf0\xb7\xc8\xdd\x9b\x96\xfe\x26\x10\x6c\x76\xcd\x2d\x5f\x3d\x94\x82\x09\x09\x81\x3c\x15\xdf\xb7\xfb\x27\x60\x7f\x7f\xe4\xab\x8f\x15\xd2\x10\x6e\xf9\x71\xb7\x39\x6e\xcb\x5d\xbe\x7a\x50\xe4\xa6\x12\x4c\x4a\x21\x91\xa2\x82\x43\xc0\x05\x27\x46\x1c\xd6\xe2\x4a\xa4\xde\x34\x92\xb9\x70\x4c\x24\xa3\x4e\x70\x5d\x30\x54\xfe\xf2\xd0\xcd\xff\xde\xd2\x80\xf4\x75\xf5\xab\x52\x60\x32\x3a\xf4\xb7\xbf\x05\xf8\x8f\xe0\xe2\x9b\x16\xf9\x55\x60\x2d\x80\x69\xdb\x30\xf4\x0e\x01\xe5\xda\xa4\xa4\x60\x42\x6b\x7f\x68\x10\xc6\x94\x9f\x20\xd8\x36\x37\xb0\x6f\x6e\x20\xb3\x7f\xf3\x79\xcc\xc0\x9f\x99\x11\x35\x90\x35\xa1\xa7\x5a\x41\xb0\x49\xcd\xbb\x02\x95\xbf\x4e\x52\x5c\x38\x4e\xba\x03\x55\xa8\x2a\xaa\x52\xaf\x09\x89\x89\x84\xa0\x15\x8c\x62\xb0\x69\x6e\xe0\xb1\x3c\x94\x05\xde\xf4\x6b\x49\x21\x94\x12\xe7\xe4\x8d\x62\x55\x43\x60\xd5\x84\x4b\x1d\xea\xeb\xfe\x15\xa3\xfd\xb0\x2c\x11\xa6\x97\xd6\x9c\xc8\xf8\x68\xad\x50\xc1\x06\x2f\x05\x44\x70\x5b\x4a\xc1\x18\x6a\x5a\x02\x41\xf7\x69\xf2\x00\x4a\x22\xde\x36\x48\x12\xae\xf2\xd5\x83\x33\x6d\x93\xa6\xff\xca\x57\x0f\x67\x74\x4b\xe2\x37\xf2\x44\xb9\xb3\x16\x82\x2c\xb5\xf6\x38\x73\x54\xfd\x04\xba\x8f\x26\x42\x7d\x24\x8e\x13\x2e\x5d\xef\xb2\xe3\xfe\x65\xb3\xdb\xe6\xab\x87\x2b\x91\x8a\x96\x88\x25\x88\xd1\x13\x87\x40\x89\xc6\xc7\x35\x60\xee\x5c\x4a\x34\xd0\xb8\xd7\x3a\xfa\x11\x9b\x9f\xc0\x0a\x82\x30\x50\x75\x4f\x60\x87\xc9\x48\xa5\x26\x54\xd9\xb3\xc4\x91\x30\xe1\x99\x55\x31\xb8\x7f\x8a\x11\x55\x35\x88\x26\xed\xd9\x73\x8b\xf9\x2f\xf4\xcc\xbe\x8b\xe8\x49\xa2\x77\x3f\x53\x8e\xc7\xa3\x81\x61\xda\x5d\xf1\x62\x6f\x0e\xe5\xef\x31\x0b\x32\x87\x88\xe9\x75\xcd\x68\xab\x12\xc2\x95\x34\x52\x2e\x7a\xd6\x83\x4e\x2c\x94\x4a\x4a\xc1\x15\xa2\xdc\x26\x6d\x18\xfb\x42\xdc\xb4\x0a\x63\x75\xef\xab\x29\x0c\xbb\x38\x84\x2c\x0e\xd7\x98\xd6\x3b\x47\x23\x0f\x44\x07\xd1\x82\xf8\x67\xdb\xce\xa6\xe1\x51\x3f\x7e\xf2\x77\x29\x3b\xd2\x64\x7e\x53\x90\x8e\x43\xee\x99\x49\x08\x99\xb6\x48\x07\xb2\x0d\x22\xb8\x39\x34\x37\xa3\x0d\x74\x1f\x16\x76\x26\x8a\x2a\xcb\x1c\xb7\xbf\x57\x7e\x98\x3d\x9a\xe5\xd3\xc8\xc9\xb6\x35\x44\xe7\x75\x71\x3f\x5f\x98\xa2\x83\x34\xe4\x42\x7d\x87\x5c\xd5\x49\x59\x53\x86\xbf\xa7\xfc\x67\xf6\xe3\x87\x1f\xa1\x74\x26\x42\x4a\x34\x7d\x09\xe8\x98\x3a\x8e\x12\x47\x57\x10\x74\x99\x97\x83\x7e\x96\xca\xfc\x1c\x8a\x6d\x19\x4f\xf3\xeb\xa8\x54\xf4\x4a\x16\x04\x2a\x51\x5e\x5a\xdf\x98\x34\x4d\x17\x34\xae\x5b\xc2\x48\xa9\x08\x1e\x6d\xb1\xcc\x7b\x73\xf5\xaa\x10\xcc\xe4\xdc\xea\xf9\x2f\x50\x2b\xd5\xb4\xf0\xf9\xf9\x44\x55\x7d\x29\xd6\xa5\x38\x3f\x37\x92\x9e\x89\x74\x7f\x12\x8e\xae\xf4\x64\x4e\x0c\xfe\x7a\x5e\xad\x4b\x71\xe1\x8a\xc8\x7b\xd7\xb1\x82\x86\xd5\x45\xb0\xeb\x52\x03\xdf\x4d\x8e\xfa\x46\x1c\xd2\x34\xf7\x8b\xe8\x26\xb7\x06\x3f\x1e\x0e\x87\x7c\x44\x1d\x43\xe2\x90\xff\xba\x60\x7f\xac\xcf\x84\x5f\xee\x61\x29\xdf\x68\xcd\xc6\x41\xad\x7a\x67\x04\x9a\x30\x8d\x11\x35\x17\x1d\x65\xfc\x9c\x3e\xea\x27\x52\xb5\xed\x34\x25\x54\x91\xf3\xbd\x11\x2d\x35\x0c\x90\x84\x21\x1d\xc1\xbc\x73\x46\xe8\x85\x63\x97\x43\x61\x6e\x86\xa9\xe9\x01\xc3\x8a\xca\x56\x59\x5e\xdf\xbd\x16\x91\xe6\x1e\x83\x75\xe9\xef\x5d\x30\x60\xeb\x25\xa9\x1d\xe9\xad\xcd\x40\x43\x58\x90\x4a\x48\x72\x9f\x47\xf5\x77\x32\x14\xdb\xe4\xce\x91\x86\xe7\x1a\xa9\x8f\x96\xbf\xa4\x22\xb6\xed\xf3\xbd\x26\xc7\xee\x71\x6e\xce\xc5\xfc\x55\x3f\xde\xf6\x3e\x63\xee\x3e\x37\x4d\x7e\xb8\x2d\x59\x96\xe5\xe5\x45\xb6\x42\x42\x4c\x2a\x74\x61\x6a\x9a\x4b\x53\xa0\xfd\x69\x7a\xc6\xa0\xa2\x15\xec\xa2\x48\xde\xc5\xd5\x79\x52\x1f\x10\xa6\xb9\x2d\x4f\xda\x77\xba\x79\x11\xae\xe0\xb7\x6f\x13\xea\x70\x76\x78\xdd\x6c\x3c\x8d\x60\x2d\x4a\x45\x4b\xc1\xef\x16\x41\xd7\xf1\xdc\x65\x85\x09\x0b\xdc\x1b\x4c\xb3\x7b\xbb\xdd\xe6\xde\x30\x51\x12\x9d\xcf\x01\x58\x97\xe3\x15\x13\x48\x41\x03\xd0\xa1\x19\x43\xf7\x41\x08\x80\xfd\xf8\x86\x24\xa7\xfc\x14\x6c\x9a\xb7\x17\x5d\x91\x42\x9d\x06\x33\xce\xc4\xe6\xfa\x0e\x45\x8c\x48\x35\xd8\xe5\x60\x0b\x9c\x95\x69\xea\xe4\x74\x19\xd4\xea\x3f\x49\x45\x6f\x6e\x48\xf3\xb0\x6e\xa4\x7e\xb9\xda\x46\xe5\xca\x50\xc2\xaf\x57\x66\x5e\x74\x86\xec\xf7\xfb\x29\x52\xbc\xe8\xe7\x2b\xc9\xef\x6c\x5f\xe0\x72\x24\x38\x5d\x28\xfe\x8f\xda\xd0\x01\xfe\x63\x49\xae\x50\xc1\xd1\xf5\xbe\xe0\x76\x53\xae\x67\x9d\x84\x31\xee\x40\x86\xf8\x8f\xb8\x68\x05\x12\x3d\x95\x44\xcd\x20\xd9\x84\xeb\xcb\x7d\xcb\xd0\x24\x0b\x1b\xd7\xce\x74\x93\xe9\xc0\xeb\x5e\xf5\xb5\xd2\xe3\xdf\x4d\x46\x5d\x67\xbc\xd8\xbb\xdf\xb7\x7d\x28\x56\x5e\x26\x2f\x35\xb6\xbe\x5e\x60\x3c\xee\x68\xe6\x4a\x99\x82\x40\x83\x9b\x58\xfc\x37\x66\x04\x99\xe1\xa4\x13\x23\x37\x25\xd1\xa2\x63\x4d\xe0\xfd\xfc\x33\xa1\x33\x2f\xa2\x11\x61\x70\x6c\x08\xff\xef\xbe\xc2\x05\x75\x42\x73\x0c\x05\x82\x2e\x89\x1c\xce\x2e\x7d\x39\x96\xe9\x64\x8c\x7a\xfc\x42\xf1\x7b\x6c\xd6\xc7\xba\xa2\x4c\x11\x99\xe8\x39\xe2\x3e\x0c\x13\x89\x7a\x6f\x88\xdb\xee\x49\xac\xdb\x33\x62\x0c\x74\xaf\xec\x94\xe0\x38\xb5\x0b\x4b\x0f\x4c\x41\x0a\xb2\xd1\xc1\x43\xb8\xe6\xbf\xe8\x96\xd8\xe9\x30\x04\x75\xc7\x72\x33\xe9\x12\xcb\xbe\x06\xe8\x2f\xdc\x7d\xfe\x8c\x58\x65\x3d\xf9\x11\x1e\xf1\x4f\x07\xa1\x30\x37\x75\xe2\xeb\x60\x55\x4c\xbc\xc1\x9a\x62\x4c\x78\x9c\x78\xe1\x79\x27\x53\xcd\xbc\xec\x61\x08\x63\xb4\x69\x69\x9b\xbf\xd5\x54\x91\xa4\x6d\x50\xa9\xe3\xf5\x26\x51\xd3\xb5\xf1\x46\x50\x5d\x45\xa6\x66\x3c\xef\x70\x7f\x36\x58\x98\x5a\xee\xfb\x35\x4e\xd5\x2f\x3b\xd5\x55\xb9\xa0\x8d\xc6\x8d\x28\xda\x50\x20\x39\x33\x61\x68\x9e\xd9\x44\xe9\x67\x0d\xfd\xea\x77\x42\x39\x26\x37\x98\x6c\xf2\xc9\xac\x9d\xa8\x29\x1b\xfd\x7c\xac\xdb\x8b\x5f\xce\x3b\x48\x93\x30\x76\xa9\x1f\x79\x3a\x60\xf3\x65\x81\x37\xce\x0c\x82\xa8\xf2\xef\x11\x4e\x8e\x11\x24\x61\x21\x54\x3d\xb1\xc5\xdd\xc6\x09\xbe\x47\x17\xce\xec\xf3\x21\xdb\x21\x54\xec\xd2\xd6\x91\xf9\x69\xbf\x3a\x43\x6a\x6f\x48\xe9\x18\x6d\xae\xc7\xbb\x4f\xae\x34\x71\xa7\x18\xdf\x36\xc8\x5e\x3f\x81\xfe\x9f\x81\x31\x7e\x5d\xb2\xad\xcc\x5b\xed\xaa\xb5\xff\x6a\xbe\x5c\x4f\x45\x75\xaf\x9f\x00\xb3\x27\xee\xd3\xe4\xdb\x09\x95\xc3\x9a\xd5\xdd\x91\x2b\xcb\xbf\x40\xfd\xa8\x51\x75\xf9\x10\x9c\xe8\xf3\x59\x67\xbb\x38\x87\x6c\x63\xb7\x8d\x87\x9d\xd1\x84\x34\x46\x8c\x97\x7b\xc8\x96\x20\x59\xd6\x13\xb4\x19\xf7\x94\x60\x47\x42\x79\x73\x51\x6e\x60\xdf\x1a\xae\x74\xb4\x36\x5b\xb6\xe9\xa8\xfe\x45\x5d\x7d\xaf\x9f\x49\xd4\xe4\x8d\x62\xe2\xa0\xf7\xe9\x94\x6e\xdd\x49\xa7\x6b\xc6\xab\x9e\x77\xb4\x01\xc7\xe6\x16\xd5\x73\xa7\xb8\x2c\xcb\xf1\x05\x22\x77\x65\x35\x21\x57\xc2\x55\xeb\x7a\x64\xa8\xd5\xa4\xf4\x4d\x81\xb5\x6e\xb9\xfe\xec\x3c\x13\x85\xc5\xe1\x33\x5d\x42\x8f\x78\x3a\x25\x61\xd8\xba\x28\xe1\xbe\x9e\x59\x10\x19\xca\x7c\xcf\xfa\x19\xab\x7e\x46\x74\x99\x4d\xec\xc5\x6d\x60\x92\x42\x33\x59\x11\x7b\x2f\x58\x9b\x75\x9e\x3d\x51\x62\x6e\x07\x67\x81\x11\x4b\x6a\xc1\x30\x91\xfd\x09\xb7\x7f\xb0\xd7\x51\x30\xcb\x26\x28\x38\xb9\x4b\xe7\xa7\x25\xa7\xa5\x07\xe5\x35\x91\x54\x0d\x7b\x75\x3f\x97\x96\x08\x7d\x17\xb0\x1d\xce\xa4\xd8\xea\x7f\x01\x00\x00\xff\xff\xee\x63\xf8\x9f\x67\x1a\x00\x00"),
		},
	}
	fs["/"].(*vfsgen۰DirInfo).entries = []os.FileInfo{
		fs["/assets"].(os.FileInfo),
	}
changes.go
@@ -0,0 +1,171 @@
package main

import (
	"fmt"
	"html/template"
	"net/http"
	"net/url"
	"os"
	"sort"

	"dmitri.shuralyov.com/app/changes/component"
	"dmitri.shuralyov.com/service/change"
	"github.com/shurcooL/htmlg"
	"github.com/shurcooL/httperror"
	"github.com/shurcooL/octiconssvg"
	"golang.org/x/net/html"
	"golang.org/x/net/html/atom"
)

var changesHTML = template.Must(template.New("").Parse(`{{define "Header"}}<html>
	<head>
{{.AnalyticsHTML}}		<title>{{with .PageName}}{{.}} - {{end}}Go Changes</title>
		<meta name="viewport" content="width=device-width">
		<link href="/assets/fonts/fonts.css" rel="stylesheet" type="text/css">
		<link href="/assets/style.css" rel="stylesheet" type="text/css">
	</head>
	<body style="margin: 0; position: relative;">
		<header style="background-color: hsl(209, 51%, 92%);">
			<div style="max-width: 800px; margin: 0 auto 0 auto; padding: 0 15px 0 15px;">
				<a class="black" href="/"                                      ><strong style="padding: 15px 0 15px 0; display: inline-block;">Go Changes</strong></a>
				<a class="black" href="/-/packages" style="padding-left: 30px;"><span   style="padding: 15px 0 15px 0; display: inline-block;">Packages</span></a>
			</div>
		</header>

		<main style="max-width: 800px; margin: 0 auto 0 auto; padding: 0 15px 120px 15px;">
			{{end}}

			{{define "About"}}<h3 style="margin-top: 30px;">About</h3>

			<p>Go Changes shows changes for Go packages.
			It's just like <a href="https://goissues.org">goissues.org</a>, but for changes (CLs, PRs, etc.).</p>

			<p>To view changes of a Go package with a given import path, navigate to <code>gochanges.org/import/path</code>
			using your browser's address bar (<kbd>⌘Cmd</kbd>+<kbd>L</kbd> or <kbd>Ctrl</kbd>+<kbd>L</kbd>).</p>

			<p>Supported import paths include:</p>

			<ul>
			<li><a href="/-/packages#stdlib">Standard library</a> (e.g., <code>io</code>, <code>net/http</code>, etc.),</li>
			<li><a href="/-/packages#subrepo">Sub-repositories</a> (i.e., <code>golang.org/x/...</code>).</li>
			</ul>

			<p>Import paths of 3rd party packages (e.g., <code>github.com/...</code>) are not supported at this time.</p>

			<p>It's a simple website with a narrow scope. Enjoy. ʕ◔ϖ◔ʔ</p>
			{{end}}

			{{define "Trailer"}}
		</main>

		<footer style="background-color: hsl(209, 51%, 92%); position: absolute; bottom: 0; left: 0; right: 0;">
			<div style="max-width: 800px; margin: 0 auto 0 auto; padding: 0 15px 0 15px; text-align: right;">
				<span style="padding: 15px 0 15px 0; display: inline-block;"><a href="https://dmitri.shuralyov.com/website/gido/...$issues">Website Issues</a></span>
			</div>
		</footer>
	</body>
</html>
{{end}}`))

// serveChanges serves a list of changes for the package with import path pkg.
func (h *handler) serveChanges(w http.ResponseWriter, req *http.Request, pkg string) error {
	if req.Method != http.MethodGet {
		return httperror.Method{Allowed: []string{http.MethodGet}}
	}
	filter, err := changeStateFilter(req.URL.Query())
	if err != nil {
		return httperror.BadRequest{Err: err}
	}

	h.s.IssuesAndChangesMu.RLock()
	ic, ok := h.s.IssuesAndChanges[pkg]
	h.s.IssuesAndChangesMu.RUnlock()
	if !ok {
		return os.ErrNotExist
	}
	var cs []change.Change
	switch {
	case filter == change.FilterOpen:
		cs = ic.OpenChanges
	case filter == change.FilterClosedMerged:
		cs = ic.ClosedChanges
	case filter == change.FilterAll:
		cs = append(ic.OpenChanges, ic.ClosedChanges...) // TODO: Measure if slow, optimize if needed.
		sort.Slice(cs, func(i, j int) bool { return cs[i].ID > cs[j].ID })
	}
	openCount := uint64(len(ic.OpenChanges))
	closedCount := uint64(len(ic.ClosedChanges))

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err = h.executeTemplate(w, req, "Header", map[string]interface{}{
		"PageName":      pkg,
		"AnalyticsHTML": template.HTML(h.analyticsHTML),
	})
	if err != nil {
		return err
	}
	heading := htmlg.NodeComponent{
		Type: html.ElementNode, Data: atom.H2.String(),
		Attr:       []html.Attribute{{Key: atom.Style.String(), Val: "margin-top: 30px;"}},
		FirstChild: htmlg.Text(pkg),
	}
	if pkg == otherPackages {
		heading.Data, heading.FirstChild = atom.H3.String(), htmlg.Text("Other Go Issues/Changes")
	}
	tabnav := tabnav{
		Tabs: []tab{
			{
				Content: contentCounter{
					Content: iconText{Icon: octiconssvg.IssueOpened, Text: "Issues"},
					Count:   len(ic.OpenIssues),
				},
				URL: h.rtr.IssuesURL(pkg),
			},
			{
				Content: contentCounter{
					Content: iconText{Icon: octiconssvg.GitPullRequest, Text: "Changes"},
					Count:   len(ic.OpenChanges),
				},
				URL:      h.rtr.ChangesURL(pkg),
				Selected: true,
			},
		},
	}
	var es []component.ChangeEntry
	for _, c := range cs {
		es = append(es, component.ChangeEntry{Change: c, BaseURI: "https://golang.org/cl"})
	}
	changes := component.Changes{
		ChangesNav: component.ChangesNav{
			OpenCount:     openCount,
			ClosedCount:   closedCount,
			Path:          req.URL.Path,
			Query:         req.URL.Query(),
			StateQueryKey: stateQueryKey,
		},
		Filter:  filter,
		Entries: es,
	}
	err = htmlg.RenderComponents(w, heading, subheading{pkg}, tabnav, changes)
	if err != nil {
		return err
	}
	err = h.executeTemplate(w, req, "Trailer", nil)
	return err
}

// changeStateFilter parses the change state filter from query,
// returning an error if the value is unsupported.
func changeStateFilter(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)
	}
}
html.go
@@ -0,0 +1,80 @@
package main

import (
	"fmt"

	"github.com/shurcooL/htmlg"
	"golang.org/x/net/html"
	"golang.org/x/net/html/atom"
)

// TODO: Dedup with .../app/changes/display.go and elsewhere.

// tabnav is a left-aligned horizontal row of tabs Primer CSS component.
//
// http://primercss.io/nav/#tabnav
type tabnav struct {
	Tabs []tab
}

func (t tabnav) Render() []*html.Node {
	nav := &html.Node{
		Type: html.ElementNode, Data: atom.Nav.String(),
		Attr: []html.Attribute{{Key: atom.Class.String(), Val: "tabnav-tabs"}},
	}
	for _, t := range t.Tabs {
		htmlg.AppendChildren(nav, t.Render()...)
	}
	return []*html.Node{htmlg.DivClass("tabnav", nav)}
}

// tab is a single tab entry within a tabnav.
type tab struct {
	Content  htmlg.Component
	URL      string
	Selected bool
}

func (t tab) Render() []*html.Node {
	aClass := "tabnav-tab"
	if t.Selected {
		aClass += " selected"
	}
	a := &html.Node{
		Type: html.ElementNode, Data: atom.A.String(),
		Attr: []html.Attribute{
			{Key: atom.Href.String(), Val: t.URL},
			{Key: atom.Class.String(), Val: aClass},
		},
	}
	htmlg.AppendChildren(a, t.Content.Render()...)
	return []*html.Node{a}
}

type contentCounter struct {
	Content htmlg.Component
	Count   int
}

func (cc contentCounter) Render() []*html.Node {
	var ns []*html.Node
	ns = append(ns, cc.Content.Render()...)
	ns = append(ns, htmlg.SpanClass("counter", htmlg.Text(fmt.Sprint(cc.Count))))
	return ns
}

// iconText is an icon with text on the right.
// Icon must be not nil.
type iconText struct {
	Icon func() *html.Node // Must be not nil.
	Text string
}

func (it iconText) Render() []*html.Node {
	icon := htmlg.Span(it.Icon())
	icon.Attr = append(icon.Attr, html.Attribute{
		Key: atom.Style.String(), Val: "margin-right: 4px;",
	})
	text := htmlg.Text(it.Text)
	return []*html.Node{icon, text}
}
issues.go
@@ -0,0 +1,193 @@
package main

import (
	"fmt"
	"html/template"
	"net/http"
	"net/url"
	"os"
	"sort"

	"github.com/shurcooL/htmlg"
	"github.com/shurcooL/httperror"
	"github.com/shurcooL/issues"
	"github.com/shurcooL/issuesapp/component"
	"github.com/shurcooL/octiconssvg"
	"golang.org/x/net/html"
	"golang.org/x/net/html/atom"
)

var issuesHTML = template.Must(template.New("").Parse(`{{define "Header"}}<html>
	<head>
{{.AnalyticsHTML}}		<title>{{with .PageName}}{{.}} - {{end}}Go Issues</title>
		<meta name="viewport" content="width=device-width">
		<link href="/assets/fonts/fonts.css" rel="stylesheet" type="text/css">
		<link href="/assets/style.css" rel="stylesheet" type="text/css">
	</head>
	<body style="margin: 0; position: relative;">
		<header style="background-color: hsl(209, 51%, 92%);">
			<div style="max-width: 800px; margin: 0 auto 0 auto; padding: 0 15px 0 15px;">
				<a class="black" href="/"                                      ><strong style="padding: 15px 0 15px 0; display: inline-block;">Go Issues</strong></a>
				<a class="black" href="/-/packages" style="padding-left: 30px;"><span   style="padding: 15px 0 15px 0; display: inline-block;">Packages</span></a>
			</div>
		</header>

		<main style="max-width: 800px; margin: 0 auto 0 auto; padding: 0 15px 120px 15px;">
			{{end}}

			{{define "About"}}<h3 style="margin-top: 30px;">About</h3>

			<p>Go Issues shows issues for Go packages.
			It's just like <a href="https://godoc.org">godoc.org</a>, but for issues.</p>

			<p>To view issues of a Go package with a given import path, navigate to <code>goissues.org/import/path</code>
			using your browser's address bar (<kbd>⌘Cmd</kbd>+<kbd>L</kbd> or <kbd>Ctrl</kbd>+<kbd>L</kbd>).</p>

			<p>Supported import paths include:</p>

			<ul>
			<li><a href="/-/packages#stdlib">Standard library</a> (e.g., <code>io</code>, <code>net/http</code>, etc.),</li>
			<li><a href="/-/packages#subrepo">Sub-repositories</a> (i.e., <code>golang.org/x/...</code>).</li>
			</ul>

			<p>Import paths of 3rd party packages (e.g., <code>github.com/...</code>) are not supported at this time.</p>

			<p>It's a simple website with a narrow scope. Enjoy. ʕ◔ϖ◔ʔ</p>
			{{end}}

			{{define "Trailer"}}
		</main>

		<footer style="background-color: hsl(209, 51%, 92%); position: absolute; bottom: 0; left: 0; right: 0;">
			<div style="max-width: 800px; margin: 0 auto 0 auto; padding: 0 15px 0 15px; text-align: right;">
				<span style="padding: 15px 0 15px 0; display: inline-block;"><a href="https://dmitri.shuralyov.com/website/gido/...$issues">Website Issues</a></span>
			</div>
		</footer>
	</body>
</html>
{{end}}`))

// serveIssues serves a list of issues for the package with import path pkg.
func (h *handler) serveIssues(w http.ResponseWriter, req *http.Request, pkg string) error {
	if req.Method != http.MethodGet {
		return httperror.Method{Allowed: []string{http.MethodGet}}
	}
	filter, err := issueStateFilter(req.URL.Query())
	if err != nil {
		return httperror.BadRequest{Err: err}
	}

	h.s.IssuesAndChangesMu.RLock()
	ic, ok := h.s.IssuesAndChanges[pkg]
	h.s.IssuesAndChangesMu.RUnlock()
	if !ok {
		return os.ErrNotExist
	}
	var is []issues.Issue
	switch {
	case filter == issues.StateFilter(issues.OpenState):
		is = ic.OpenIssues
	case filter == issues.StateFilter(issues.ClosedState):
		is = ic.ClosedIssues
	case filter == issues.AllStates:
		is = append(ic.OpenIssues, ic.ClosedIssues...) // TODO: Measure if slow, optimize if needed.
		sort.Slice(is, func(i, j int) bool { return is[i].ID > is[j].ID })
	}
	openCount := uint64(len(ic.OpenIssues))
	closedCount := uint64(len(ic.ClosedIssues))

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err = h.executeTemplate(w, req, "Header", map[string]interface{}{
		"PageName":      pkg,
		"AnalyticsHTML": template.HTML(h.analyticsHTML),
	})
	if err != nil {
		return err
	}
	heading := htmlg.NodeComponent{
		Type: html.ElementNode, Data: atom.H2.String(),
		Attr:       []html.Attribute{{Key: atom.Style.String(), Val: "margin-top: 30px;"}},
		FirstChild: htmlg.Text(pkg),
	}
	if pkg == otherPackages {
		heading.Data, heading.FirstChild = atom.H3.String(), htmlg.Text("Other Go Issues/Changes")
	}
	tabnav := tabnav{
		Tabs: []tab{
			{
				Content: contentCounter{
					Content: iconText{Icon: octiconssvg.IssueOpened, Text: "Issues"},
					Count:   len(ic.OpenIssues),
				},
				URL:      h.rtr.IssuesURL(pkg),
				Selected: true,
			},
			{
				Content: contentCounter{
					Content: iconText{Icon: octiconssvg.GitPullRequest, Text: "Changes"},
					Count:   len(ic.OpenChanges),
				},
				URL: h.rtr.ChangesURL(pkg),
			},
		},
	}
	title := ImportPathToFullPrefix(pkg)
	newIssue := htmlg.NodeComponent{
		Type: html.ElementNode, Data: atom.Div.String(),
		Attr:       []html.Attribute{{Key: atom.Style.String(), Val: "text-align: right;"}},
		FirstChild: htmlg.A("New Issue", "https://golang.org/issue/new?title="+url.QueryEscape(title)),
	}
	var es []component.IssueEntry
	for _, i := range is {
		es = append(es, component.IssueEntry{Issue: i, BaseURI: "https://golang.org/issue"})
	}
	issues := component.Issues{
		IssuesNav: component.IssuesNav{
			OpenCount:     openCount,
			ClosedCount:   closedCount,
			Path:          req.URL.Path,
			Query:         req.URL.Query(),
			StateQueryKey: stateQueryKey,
		},
		Filter:  filter,
		Entries: es,
	}
	err = htmlg.RenderComponents(w, heading, subheading{pkg}, tabnav, newIssue, issues)
	if err != nil {
		return err
	}
	err = h.executeTemplate(w, req, "Trailer", nil)
	return err
}

type subheading struct{ Pkg string }

func (s subheading) Render() []*html.Node {
	switch s.Pkg {
	case otherPackages:
		return []*html.Node{htmlg.P(htmlg.Text("Issues and changes that don't fit into any existing Go package."))}
	default:
		return nil
	}
}

const (
	// stateQueryKey is name of query key for controlling issue/change state filter.
	stateQueryKey = "state"
)

// issueStateFilter parses the issue state filter from query,
// returning an error if the value is unsupported.
func issueStateFilter(query url.Values) (issues.StateFilter, error) {
	selectedTabName := query.Get(stateQueryKey)
	switch selectedTabName {
	case "":
		return issues.StateFilter(issues.OpenState), nil
	case "closed":
		return issues.StateFilter(issues.ClosedState), nil
	case "all":
		return issues.AllStates, nil
	default:
		return "", fmt.Errorf("unsupported state filter value: %q", selectedTabName)
	}
}
main.go
@@ -1,6 +1,6 @@
// gido is the command that powers the https://goissues.org website.
// gido is the command that powers the https://goissues.org and https://gochanges.org websites.
package main

import (
	"context"
	"encoding/json"
@@ -10,64 +10,74 @@ import (
	"io"
	"io/ioutil"
	"log"
	"mime"
	"net/http"
	"net/url"
	"os"
	"os/signal"
	"path"
	"sort"
	"strings"

	"dmitri.shuralyov.com/website/gido/assets"
	"github.com/shurcooL/htmlg"
	"github.com/shurcooL/httperror"
	"github.com/shurcooL/httpgzip"
	"github.com/shurcooL/issues"
	"github.com/shurcooL/issuesapp/component"
	"golang.org/x/net/html"
	"golang.org/x/net/html/atom"
)

var (
	httpFlag          = flag.String("http", ":8080", "Listen for HTTP connections on this address.")
	routerFlag        = flag.String("router", "dev", `Routing system to use ("dot-org" for production use, "dev" for localhost development).`)
	analyticsFileFlag = flag.String("analytics-file", "", "Optional path to file containing analytics HTML to insert at the beginning of <head>.")
)

func main() {
	flag.Parse()

	var router Router
	switch *routerFlag {
	case "dot-org":
		router = dotOrgRouter{}
	case "dev":
		router = devRouter{}
	default:
		fmt.Fprintf(os.Stderr, "invalid -router flag value %q\n", *routerFlag)
		flag.Usage()
		os.Exit(2)
	}

	ctx, cancel := context.WithCancel(context.Background())
	go func() {
		sigint := make(chan os.Signal, 1)
		signal.Notify(sigint, os.Interrupt)
		<-sigint
		cancel()
	}()

	err := run(ctx)
	err := run(ctx, router, *analyticsFileFlag)
	if err != nil {
		log.Fatalln(err)
	}
}

func run(ctx context.Context) error {
func run(ctx context.Context, router Router, analyticsFile string) error {
	if err := mime.AddExtensionType(".woff2", "font/woff2"); err != nil {
		return err
	}

	var analyticsHTML []byte
	if *analyticsFileFlag != "" {
	if analyticsFile != "" {
		var err error
		analyticsHTML, err = ioutil.ReadFile(*analyticsFileFlag)
		analyticsHTML, err = ioutil.ReadFile(analyticsFile)
		if err != nil {
			return err
		}
	}

	server := &http.Server{Addr: *httpFlag, Handler: top{&errorHandler{handler: (&handler{
		rtr:           router,
		analyticsHTML: analyticsHTML,
		fontsHandler:  httpgzip.FileServer(assets.Fonts, httpgzip.FileServerOptions{ServeError: httpgzip.Detailed}),
		assetsHandler: httpgzip.FileServer(assets.Assets, httpgzip.FileServerOptions{ServeError: httpgzip.Detailed}),
		s:             newService(ctx),
	}).ServeHTTP}}}
@@ -93,10 +103,11 @@ func run(ctx context.Context) error {
}

// handler handles all goissues requests. It acts like a request multiplexer,
// choosing from various endpoints and parsing the import path from URL.
type handler struct {
	rtr           Router
	analyticsHTML []byte
	fontsHandler  http.Handler
	assetsHandler http.Handler
	s             *service
}
@@ -133,132 +144,89 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, req *http.Request) error {
		}
		http.Redirect(w, req, canonicalPath, http.StatusFound)
		return nil
	}
	pkg := req.URL.Path[1:]
	return h.ServeIssues(w, req, pkg)
	return h.ServeIssuesOrChanges(w, req, pkg)
}

var pageHTML = template.Must(template.New("").Parse(`{{define "Header"}}<html>
	<head>
{{.AnalyticsHTML}}		<title>{{with .PageName}}{{.}} - {{end}}Go Issues</title>
		<meta name="viewport" content="width=device-width">
		<link href="/assets/fonts/fonts.css" rel="stylesheet" type="text/css">
		<link href="/assets/style.css" rel="stylesheet" type="text/css">
	</head>
	<body style="margin: 0; position: relative;">
		<header style="background-color: hsl(209, 51%, 92%);">
			<div style="max-width: 800px; margin: 0 auto 0 auto; padding: 0 15px 0 15px;">
				<a class="black" href="/"                                      ><strong style="padding: 15px 0 15px 0; display: inline-block;">Go Issues</strong></a>
				<a class="black" href="/-/packages" style="padding-left: 30px;"><span   style="padding: 15px 0 15px 0; display: inline-block;">Packages</span></a>
			</div>
		</header>

		<main style="max-width: 800px; margin: 0 auto 0 auto; padding: 0 15px 120px 15px;">
			{{end}}

			{{define "Trailer"}}
		</main>

		<footer style="background-color: hsl(209, 51%, 92%); position: absolute; bottom: 0; left: 0; right: 0;">
			<div style="max-width: 800px; margin: 0 auto 0 auto; padding: 0 15px 0 15px; text-align: right;">
				<span style="padding: 15px 0 15px 0; display: inline-block;"><a href="https://dmitri.shuralyov.com/website/gido/...$issues">Website Issues</a></span>
			</div>
		</footer>
	</body>
</html>
{{end}}`))

// ServeIndex serves the index page.
func (h *handler) ServeIndex(w http.ResponseWriter, req *http.Request) error {
	if req.Method != http.MethodGet {
		return httperror.Method{Allowed: []string{http.MethodGet}}
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err := pageHTML.ExecuteTemplate(w, "Header", map[string]interface{}{
	err := h.executeTemplate(w, req, "Header", map[string]interface{}{
		"AnalyticsHTML": template.HTML(h.analyticsHTML),
	})
	if err != nil {
		return err
	}

	// Write the About section.
	_, err = io.WriteString(w, `<h3 style="margin-top: 30px;">About</h3>

			<p>Go Issues shows issues for Go packages.
			It's just like <a href="https://godoc.org">godoc.org</a>, but for issues.</p>

			<p>To view issues of a Go package with a given import path, navigate to <code>goissues.org/import/path</code>
			using your browser's address bar (<kbd>⌘Cmd</kbd>+<kbd>L</kbd> or <kbd>Ctrl</kbd>+<kbd>L</kbd>).</p>

			<p>Supported import paths include:</p>

			<ul>
			<li><a href="/-/packages#stdlib">Standard library</a> (e.g., <code>io</code>, <code>net/http</code>, etc.),</li>
			<li><a href="/-/packages#subrepo">Sub-repositories</a> (i.e., <code>golang.org/x/...</code>).</li>
			</ul>

			<p>Import paths of 3rd party packages (e.g., <code>github.com/...</code>) are not supported at this time.</p>

			<p>It's a simple website with a narrow scope. Enjoy. ʕ◔ϖ◔ʔ</p>
			`)
	err = h.executeTemplate(w, req, "About", nil)
	if err != nil {
		return err
	}

	_, err = io.WriteString(w, `<h3 style="margin-top: 30px;">Popular Packages</h3>`)
	if err != nil {
		return err
	}

	// Find some popular packages to display.
	h.s.PackageIssuesMu.RLock()
	pis := h.s.PackageIssues
	h.s.PackageIssuesMu.RUnlock()
	h.s.IssuesAndChangesMu.RLock()
	ics := h.s.IssuesAndChanges
	h.s.IssuesAndChangesMu.RUnlock()
	var popular []pkg
	for _, p := range h.s.Packages {
		popular = append(popular, pkg{
			Path: p,
			Open: len(pis[p].Open),
			Path:        p,
			OpenIssues:  len(ics[p].OpenIssues),
			OpenChanges: len(ics[p].OpenChanges),
		})
	}
	sort.SliceStable(popular, func(i, j int) bool { return popular[i].Open > popular[j].Open })
	sort.SliceStable(popular, func(i, j int) bool {
		return popular[i].OpenIssues+popular[i].OpenChanges > popular[j].OpenIssues+popular[j].OpenChanges
	})
	popular = popular[:15]

	// Render the table.
	err = renderTable(w, popular)
	if err != nil {
		return err
	}

	err = pageHTML.ExecuteTemplate(w, "Trailer", nil)
	err = h.executeTemplate(w, req, "Trailer", nil)
	return err
}

// ServePackages serves a list of all known packages.
func (h *handler) ServePackages(w http.ResponseWriter, req *http.Request) error {
	if req.Method != http.MethodGet {
		return httperror.Method{Allowed: []string{http.MethodGet}}
	}

	// Gather all packages in sorted order.
	h.s.PackageIssuesMu.RLock()
	pis := h.s.PackageIssues
	h.s.PackageIssuesMu.RUnlock()
	h.s.IssuesAndChangesMu.RLock()
	ics := h.s.IssuesAndChanges
	h.s.IssuesAndChangesMu.RUnlock()
	var stdlib, subrepo []pkg
	for _, p := range h.s.Packages {
		switch isStandard(p) {
		case true:
			stdlib = append(stdlib, pkg{
				Path: p,
				Open: len(pis[p].Open),
				Path:        p,
				OpenIssues:  len(ics[p].OpenIssues),
				OpenChanges: len(ics[p].OpenChanges),
			})
		case false:
			subrepo = append(subrepo, pkg{
				Path: p,
				Open: len(pis[p].Open),
				Path:        p,
				OpenIssues:  len(ics[p].OpenIssues),
				OpenChanges: len(ics[p].OpenChanges),
			})
		}
	}

	if req.Header.Get("Accept") == "application/json" {
@@ -268,11 +236,11 @@ func (h *handler) ServePackages(w http.ResponseWriter, req *http.Request) error
		err := e.Encode(append(stdlib, subrepo...)) // TODO: Measure if slow, optimize if needed.
		return err
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err := pageHTML.ExecuteTemplate(w, "Header", map[string]interface{}{
	err := h.executeTemplate(w, req, "Header", map[string]interface{}{
		"PageName":      "Packages",
		"AnalyticsHTML": template.HTML(h.analyticsHTML),
	})
	if err != nil {
		return err
@@ -308,161 +276,66 @@ func (h *handler) ServePackages(w http.ResponseWriter, req *http.Request) error
			</p>`)
	if err != nil {
		return err
	}

	err = pageHTML.ExecuteTemplate(w, "Trailer", nil)
	err = h.executeTemplate(w, req, "Trailer", nil)
	return err
}

type pkg struct {
	Path string `json:"ImportPath"`
	Open int    `json:"OpenIssues"`
	Path        string `json:"ImportPath"`
	OpenIssues  int
	OpenChanges int
}

func renderTable(w io.Writer, pkgs []pkg) error {
	_, err := io.WriteString(w, `
			<table class="table table-sm">
				<thead>
					<tr>
						<th>Path</th>
						<th>Open Issues</th>
						<th>Open Changes</th>
					</tr>
				</thead>
				<tbody>`)
	if err != nil {
		return err
	}
	for _, p := range pkgs {
		err := html.Render(w, htmlg.TR(
			htmlg.TD(htmlg.A(p.Path, "/"+p.Path)),
			htmlg.TD(htmlg.Text(fmt.Sprint(p.Open))),
			htmlg.TD(htmlg.Text(fmt.Sprint(p.OpenIssues))),
			htmlg.TD(htmlg.Text(fmt.Sprint(p.OpenChanges))),
		))
		if err != nil {
			return err
		}
	}
	_, err = io.WriteString(w, `</tbody>
			</table>`)
	return err
}

// ServeIssues serves a list of issues for the package with import path pkg.
func (h *handler) ServeIssues(w http.ResponseWriter, req *http.Request, pkg string) error {
	if req.Method != http.MethodGet {
		return httperror.Method{Allowed: []string{http.MethodGet}}
	}
	filter, err := stateFilter(req.URL.Query())
	if err != nil {
		return httperror.BadRequest{Err: err}
	}

	h.s.PackageIssuesMu.RLock()
	pi, ok := h.s.PackageIssues[pkg]
	h.s.PackageIssuesMu.RUnlock()
	if !ok {
		return os.ErrNotExist
	}
	var is []issues.Issue
	switch {
	case filter == issues.StateFilter(issues.OpenState):
		is = pi.Open
	case filter == issues.StateFilter(issues.ClosedState):
		is = pi.Closed
	case filter == issues.AllStates:
		is = append(pi.Open, pi.Closed...) // TODO: Measure if slow, optimize if needed.
		sort.Slice(is, func(i, j int) bool { return is[i].ID > is[j].ID })
	}
	openCount := uint64(len(pi.Open))
	closedCount := uint64(len(pi.Closed))

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err = pageHTML.ExecuteTemplate(w, "Header", map[string]interface{}{
		"PageName":      pkg,
		"AnalyticsHTML": template.HTML(h.analyticsHTML),
	})
	if err != nil {
		return err
	}
	heading := htmlg.NodeComponent{
		Type: html.ElementNode, Data: atom.H2.String(),
		Attr:       []html.Attribute{{Key: atom.Style.String(), Val: "margin-top: 30px;"}},
		FirstChild: htmlg.Text(pkg),
	}
	if pkg == otherPackages {
		heading.Data, heading.FirstChild = atom.H3.String(), htmlg.Text("Other Go Issues")
	}
	title := ImportPathToFullPrefix(pkg)
	newIssue := htmlg.NodeComponent{
		Type: html.ElementNode, Data: atom.Div.String(),
		Attr:       []html.Attribute{{Key: atom.Style.String(), Val: "text-align: right;"}},
		FirstChild: htmlg.A("New Issue", "https://golang.org/issue/new?title="+url.QueryEscape(title)),
	}
	var es []component.IssueEntry
	for _, i := range is {
		es = append(es, component.IssueEntry{Issue: i, BaseURI: "https://golang.org/issue"})
	}
	issues := component.Issues{
		IssuesNav: component.IssuesNav{
			OpenCount:     openCount,
			ClosedCount:   closedCount,
			Path:          req.URL.Path,
			Query:         req.URL.Query(),
			StateQueryKey: stateQueryKey,
		},
		Filter:  filter,
		Entries: es,
	}
	err = htmlg.RenderComponents(w, heading, subheading{pkg}, newIssue, issues)
	if err != nil {
		return err
	}
	err = pageHTML.ExecuteTemplate(w, "Trailer", nil)
	return err
}

type subheading struct{ Pkg string }

func (s subheading) Render() []*html.Node {
	switch s.Pkg {
	case otherPackages:
		return []*html.Node{htmlg.P(htmlg.Text("Issues that don't fit into any existing Go package."))}
// ServeIssuesOrChanges serves a list of issues or changes for the package with import path pkg.
func (h *handler) ServeIssuesOrChanges(w http.ResponseWriter, req *http.Request, pkg string) error {
	switch changes := h.rtr.WantChanges(req); {
	case !changes:
		return h.serveIssues(w, req, pkg)
	case changes:
		return h.serveChanges(w, req, pkg)
	default:
		return nil
		panic("unreachable")
	}
}

const (
	// stateQueryKey is name of query key for controlling issue state filter.
	stateQueryKey = "state"
)

// stateFilter parses the issue state filter from query,
// returning an error if the value is unsupported.
func stateFilter(query url.Values) (issues.StateFilter, error) {
	selectedTabName := query.Get(stateQueryKey)
	switch selectedTabName {
	case "":
		return issues.StateFilter(issues.OpenState), nil
	case "closed":
		return issues.StateFilter(issues.ClosedState), nil
	case "all":
		return issues.AllStates, nil
func (h *handler) executeTemplate(w io.Writer, req *http.Request, name string, data interface{}) error {
	switch changes := h.rtr.WantChanges(req); {
	case !changes:
		return issuesHTML.ExecuteTemplate(w, name, data)
	case changes:
		return changesHTML.ExecuteTemplate(w, name, data)
	default:
		return "", fmt.Errorf("unsupported state filter value: %q", selectedTabName)
	}
}

// stripPrefix returns request r with prefix of length prefixLen stripped from r.URL.Path.
// prefixLen must not be longer than len(r.URL.Path), otherwise stripPrefix panics.
// If r.URL.Path is empty after the prefix is stripped, the path is changed to "/".
func stripPrefix(r *http.Request, prefixLen int) *http.Request {
	r2 := new(http.Request)
	*r2 = *r
	r2.URL = new(url.URL)
	*r2.URL = *r.URL
	r2.URL.Path = r.URL.Path[prefixLen:]
	if r2.URL.Path == "" {
		r2.URL.Path = "/"
		panic("unreachable")
	}
	return r2
}
route.go
@@ -0,0 +1,52 @@
package main

import (
	"net/http"
	"strconv"
)

// Router provides a routing system.
type Router interface {
	// WantChanges reports whether the request req is for changes
	// rather than issues.
	WantChanges(req *http.Request) bool

	// IssuesURL returns the URL of the issues page for package pkg.
	IssuesURL(pkg string) string

	// ChangesURL returns the URL of the changes page for package pkg.
	ChangesURL(pkg string) string
}

// dotOrgRouter provides a routing system for go{issues,changes}.org.
// Pages for issues/changes are selected based on host.
type dotOrgRouter struct{}

func (dotOrgRouter) WantChanges(req *http.Request) bool {
	return req.Host == "gochanges.org"
}

func (dotOrgRouter) IssuesURL(pkg string) string {
	return "//goissues.org/" + pkg
}

func (dotOrgRouter) ChangesURL(pkg string) string {
	return "//gochanges.org/" + pkg
}

// devRouter provides routing system for local development.
// Pages for issues/changes are selected based on ?changes=1 query parameter.
type devRouter struct{}

func (devRouter) WantChanges(req *http.Request) bool {
	ok, _ := strconv.ParseBool(req.URL.Query().Get("changes"))
	return ok
}

func (devRouter) IssuesURL(pkg string) string {
	return "/" + pkg
}

func (devRouter) ChangesURL(pkg string) string {
	return "/" + pkg + "?changes=1"
}
service.go
@@ -8,37 +8,39 @@ import (
	"sort"
	"strings"
	"sync"
	"time"

	"dmitri.shuralyov.com/service/change"
	"github.com/shurcooL/issues"
	"github.com/shurcooL/users"
	"golang.org/x/build/maintner"
	"golang.org/x/build/maintner/godata"
)

type service struct {
	// PackageIssues contains issues for all packages. Map key is import path.
	// An additional entry with key otherPackages is for issues that don't fit
	// IssuesAndChanges contains issues and changes for all packages. Map key is import path.
	// An additional entry with key otherPackages is for issues and changes that don't fit
	// into any existing Go package.
	PackageIssuesMu sync.RWMutex
	PackageIssues   map[string]*pkgIssues
	IssuesAndChangesMu sync.RWMutex
	IssuesAndChanges   map[string]*Directory

	// Packages is a list of all packages. Sorted by import path, standard library first.
	Packages []string
}

type pkgIssues struct {
	Open, Closed []issues.Issue
type Directory struct {
	OpenIssues, ClosedIssues   []issues.Issue
	OpenChanges, ClosedChanges []change.Change
}

func newService(ctx context.Context) *service {
	packageIssues := emptyPackages()
	issuesAndChanges := emptyDirectories()

	// Initialize list of packages sorted by import path, standard library first.
	var packages []string
	for p := range packageIssues {
	for p := range issuesAndChanges {
		if p == otherPackages { // Don't include "other", it's not a real package.
			continue
		}
		packages = append(packages, p)
	}
@@ -48,33 +50,33 @@ func newService(ctx context.Context) *service {
		}
		return packages[i] < packages[j]
	})

	s := &service{
		PackageIssues: packageIssues,
		Packages:      packages,
		IssuesAndChanges: issuesAndChanges,
		Packages:         packages,
	}
	go s.poll(ctx)
	return s
}

func emptyPackages() map[string]*pkgIssues {
	// Initialize places for issues, using existing packages
func emptyDirectories() map[string]*Directory {
	// Initialize places for issues and changes, using existing packages
	// and their parent directories.
	packageIssues := make(map[string]*pkgIssues)
	issuesAndChanges := make(map[string]*Directory)
	for p := range existingPackages {
		elems := strings.Split(p, "/")
		for i := len(elems); i >= 1; i-- { // Iterate in reverse order so we can break out early.
			p := path.Join(elems[:i]...)
			if _, ok := packageIssues[p]; ok {
			if _, ok := issuesAndChanges[p]; ok {
				break
			}
			packageIssues[p] = new(pkgIssues)
			issuesAndChanges[p] = new(Directory)
		}
	}
	packageIssues[otherPackages] = new(pkgIssues)
	return packageIssues
	issuesAndChanges[otherPackages] = new(Directory)
	return issuesAndChanges
}

func category(importPath string) int {
	switch isStandard(importPath) {
	case true:
@@ -100,14 +102,14 @@ func (s *service) poll(ctx context.Context) {
	if err != nil {
		log.Fatalln("poll: initial initCorpus failed:", err)
	}

	for {
		packageIssues := packageIssues(repo)
		s.PackageIssuesMu.Lock()
		s.PackageIssues = packageIssues
		s.PackageIssuesMu.Unlock()
		issuesAndChanges := issuesAndChanges(repo, corpus.Gerrit())
		s.IssuesAndChangesMu.Lock()
		s.IssuesAndChanges = issuesAndChanges
		s.IssuesAndChangesMu.Unlock()
		for {
			updateError := corpus.Update(ctx)
			if updateError == maintner.ErrSplit {
				log.Println("corpus.Update: Corpus out of sync. Re-fetching corpus.")
				corpus, repo, err = initCorpus(ctx)
@@ -129,17 +131,22 @@ func initCorpus(ctx context.Context) (*maintner.Corpus, *maintner.GitHubRepo, er
	if err != nil {
		return nil, nil, fmt.Errorf("godata.Get: %v", err)
	}
	repo := corpus.GitHub().Repo("golang", "go")
	if repo == nil {
		return nil, nil, fmt.Errorf("golang/go repo not found")
		return nil, nil, fmt.Errorf("golang/go GitHub repo not found")
	}
	if corpus.Gerrit().Project("go.googlesource.com", "go") == nil {
		return nil, nil, fmt.Errorf("go.googlesource.com/go Gerrit project not found")
	}
	return corpus, repo, nil
}

func packageIssues(repo *maintner.GitHubRepo) map[string]*pkgIssues {
	packageIssues := emptyPackages()
func issuesAndChanges(repo *maintner.GitHubRepo, gerrit *maintner.Gerrit) map[string]*Directory {
	issuesAndChanges := emptyDirectories()

	// Collect issues.
	err := repo.ForeachIssue(func(i *maintner.GitHubIssue) error {
		if i.NotExist || i.PullRequest {
			return nil
		}

@@ -173,44 +180,140 @@ func packageIssues(repo *maintner.GitHubRepo) map[string]*pkgIssues {
			Replies: replies,
		}

		var added bool
		for _, p := range pkgs {
			pi := packageIssues[p]
			if pi == nil {
			ic := issuesAndChanges[p]
			if ic == nil {
				continue
			}
			switch issue.State {
			case issues.OpenState:
				pi.Open = append(pi.Open, issue)
				ic.OpenIssues = append(ic.OpenIssues, issue)
			case issues.ClosedState:
				pi.Closed = append(pi.Closed, issue)
				ic.ClosedIssues = append(ic.ClosedIssues, issue)
			}
			added = true
		}
		if !added {
			pi := packageIssues[otherPackages]
			ic := issuesAndChanges[otherPackages]
			issue.Title = i.Title
			switch issue.State {
			case issues.OpenState:
				pi.Open = append(pi.Open, issue)
				ic.OpenIssues = append(ic.OpenIssues, issue)
			case issues.ClosedState:
				pi.Closed = append(pi.Closed, issue)
				ic.ClosedIssues = append(ic.ClosedIssues, issue)
			}
		}

		return nil
	})
	if err != nil {
		panic(fmt.Errorf("internal error: ForeachIssue returned non-nil error: %v", err))
	}
	// Sort issues by ID (newest first).
	for _, p := range packageIssues {
		sort.Slice(p.Open, func(i, j int) bool { return p.Open[i].ID > p.Open[j].ID })
		sort.Slice(p.Closed, func(i, j int) bool { return p.Closed[i].ID > p.Closed[j].ID })

	// Collect changes.
	err = gerrit.ForeachProjectUnsorted(func(proj *maintner.GerritProject) error {
		root, ok := gerritProjects[proj.ServerSlashProject()]
		if !ok {
			return nil
		}
		err := proj.ForeachCLUnsorted(func(cl *maintner.GerritCL) error {
			if cl.Private || cl.Status == "" {
				return nil
			}
			state, ok := clState(cl.Status)
			if !ok {
				return nil
			}

			prefixedTitle := firstParagraph(cl.Commit.Msg)
			pkgs, title := ParsePrefixedChangeTitle(root, prefixedTitle)
			c := change.Change{
				ID:        uint64(cl.Number),
				State:     state,
				Title:     title,
				Author:    gitUser(cl.Commit.Author),
				CreatedAt: cl.Created,
				Replies:   len(cl.Messages),
			}

			var added bool
			for _, p := range pkgs {
				ic := issuesAndChanges[p]
				if ic == nil {
					continue
				}
				switch c.State {
				case change.OpenState:
					ic.OpenChanges = append(ic.OpenChanges, c)
				case change.ClosedState, change.MergedState:
					ic.ClosedChanges = append(ic.ClosedChanges, c)
				}
				added = true
			}
			if !added {
				ic := issuesAndChanges[root]
				if ic == nil {
					ic = issuesAndChanges[otherPackages]
				}
				c.Title = prefixedTitle
				switch c.State {
				case change.OpenState:
					ic.OpenChanges = append(ic.OpenChanges, c)
				case change.ClosedState, change.MergedState:
					ic.ClosedChanges = append(ic.ClosedChanges, c)
				}
			}

			return nil
		})
		return err
	})
	if err != nil {
		panic(fmt.Errorf("internal error: ForeachProjectUnsorted returned non-nil error: %v", err))
	}

	// Sort issues and changes by ID (newest first).
	for _, p := range issuesAndChanges {
		sort.Slice(p.OpenIssues, func(i, j int) bool { return p.OpenIssues[i].ID > p.OpenIssues[j].ID })
		sort.Slice(p.ClosedIssues, func(i, j int) bool { return p.ClosedIssues[i].ID > p.ClosedIssues[j].ID })
		sort.Slice(p.OpenChanges, func(i, j int) bool { return p.OpenChanges[i].ID > p.OpenChanges[j].ID })
		sort.Slice(p.ClosedChanges, func(i, j int) bool { return p.ClosedChanges[i].ID > p.ClosedChanges[j].ID })
	}
	return packageIssues

	return issuesAndChanges
}

// gerritProjects maps each supported Gerrit "server/project" to
// the import path that corresponds to the root of that project.
var gerritProjects = map[string]string{
	"go.googlesource.com/go":         "",
	"go.googlesource.com/arch":       "golang.org/x/arch",
	"go.googlesource.com/benchmarks": "golang.org/x/benchmarks",
	"go.googlesource.com/blog":       "golang.org/x/blog",
	"go.googlesource.com/build":      "golang.org/x/build",
	"go.googlesource.com/crypto":     "golang.org/x/crypto",
	"go.googlesource.com/debug":      "golang.org/x/debug",
	"go.googlesource.com/exp":        "golang.org/x/exp",
	"go.googlesource.com/image":      "golang.org/x/image",
	"go.googlesource.com/lint":       "golang.org/x/lint",
	"go.googlesource.com/mobile":     "golang.org/x/mobile",
	"go.googlesource.com/net":        "golang.org/x/net",
	"go.googlesource.com/oauth2":     "golang.org/x/oauth2",
	"go.googlesource.com/perf":       "golang.org/x/perf",
	"go.googlesource.com/playground": "golang.org/x/playground",
	"go.googlesource.com/review":     "golang.org/x/review",
	"go.googlesource.com/sync":       "golang.org/x/sync",
	"go.googlesource.com/sys":        "golang.org/x/sys",
	"go.googlesource.com/talks":      "golang.org/x/talks",
	"go.googlesource.com/term":       "golang.org/x/term",
	"go.googlesource.com/text":       "golang.org/x/text",
	"go.googlesource.com/time":       "golang.org/x/time",
	"go.googlesource.com/tools":      "golang.org/x/tools",
	"go.googlesource.com/tour":       "golang.org/x/tour",
	"go.googlesource.com/vgo":        "golang.org/x/vgo",
}

const otherPackages = "other"

// ParsePrefixedTitle parses a prefixed issue title.
@@ -237,12 +340,12 @@ func ParsePrefixedTitle(prefixedTitle string) (paths []string, title string) {
	if idx == -1 {
		return nil, prefixedTitle
	}
	prefix, title := prefixedTitle[:idx], prefixedTitle[idx+len(": "):]
	if strings.ContainsAny(prefix, "{}") {
		// TODO: Parse "x/image/{tiff,bmp}" as ["x/image/tiff", "x/image/bmp"], maybe?
		return []string{prefix}, title
		// TODO: Parse "image/{png,jpeg}" as ["image/png", "image/jpeg"], maybe?
		return []string{strings.TrimSpace(prefix)}, title
	}
	paths = strings.Split(prefix, ",")
	for i := range paths {
		paths[i] = strings.TrimSpace(paths[i])
		if strings.HasPrefix(paths[i], "x/") || paths[i] == "x" { // Map "x/..." to "golang.org/x/...".
@@ -250,10 +353,41 @@ func ParsePrefixedTitle(prefixedTitle string) (paths []string, title string) {
		}
	}
	return paths, title
}

// ParsePrefixedChangeTitle parses a prefixed change title.
// It returns a list of paths from the prefix joined with root, and the remaining change title.
// It does not try to verify whether each path is an existing Go package.
//
// Supported forms include:
//
// 	"root", "import/path: Change title."  -> ["root/import/path"],         "Change title."
// 	"root", "path1, path2: Change title." -> ["root/path1", "root/path2"], "Change title."  # Multiple comma-separated paths.
//
// If there's no path prefix (preceded by ": "), title is returned unmodified
// with a paths list containing root:
//
// 	"root", "Change title."               -> ["root"], "Change title."
//
func ParsePrefixedChangeTitle(root, prefixedTitle string) (paths []string, title string) {
	idx := strings.Index(prefixedTitle, ": ")
	if idx == -1 {
		return []string{root}, prefixedTitle
	}
	prefix, title := prefixedTitle[:idx], prefixedTitle[idx+len(": "):]
	if strings.ContainsAny(prefix, "{}") {
		// TODO: Parse "image/{png,jpeg}" as ["image/png", "image/jpeg"], maybe?
		return []string{path.Join(root, strings.TrimSpace(prefix))}, title
	}
	paths = strings.Split(prefix, ",")
	for i := range paths {
		paths[i] = path.Join(root, strings.TrimSpace(paths[i]))
	}
	return paths, title
}

// ImportPathToFullPrefix returns the an issue title prefix (including ": ") for the given import path.
// If path equals to otherPackages, an empty prefix is returned.
func ImportPathToFullPrefix(path string) string {
	switch {
	default:
@@ -278,10 +412,35 @@ func ghState(issue *maintner.GitHubIssue) issues.State {
	default:
		panic("unreachable")
	}
}

// firstParagraph returns the first paragraph of text s.
func firstParagraph(s string) string {
	i := strings.Index(s, "\n\n")
	if i == -1 {
		return s
	}
	return s[:i]
}

func clState(status string) (_ change.State, ok bool) {
	switch status {
	case "new":
		return change.OpenState, true
	case "abandoned":
		return change.ClosedState, true
	case "merged":
		return change.MergedState, true
	case "draft":
		// Treat draft CL as one that doesn't exist.
		return "", false
	default:
		panic(fmt.Errorf("unrecognized CL status %q", status))
	}
}

// ghUser converts a GitHub user into a users.User.
func ghUser(user *maintner.GitHubUser) users.User {
	return users.User{
		UserSpec: users.UserSpec{
			ID:     uint64(user.ID),
@@ -290,5 +449,18 @@ func ghUser(user *maintner.GitHubUser) users.User {
		Login:     user.Login,
		AvatarURL: fmt.Sprintf("https://avatars.githubusercontent.com/u/%d?v=4&s=96", user.ID),
		HTMLURL:   fmt.Sprintf("https://github.com/%v", user.Login),
	}
}

func gitUser(user *maintner.GitPerson) users.User {
	return users.User{
		UserSpec: users.UserSpec{
			ID:     0,  // TODO.
			Domain: "", // TODO.
		},
		Login: user.Name(), //user.Username, // TODO.
		Name:  user.Name(),
		Email: user.Email(),
		//AvatarURL: fmt.Sprintf("https://%s/accounts/%d/avatar?s=96", s.domain, user.AccountID),
	}
}
service_test.go
@@ -40,17 +40,63 @@ func TestParsePrefixedTitle(t *testing.T) {
		{ // No path prefix.
			in:        "Issue title.",
			wantPaths: nil, wantTitle: "Issue title.",
		},
	}
	for _, tc := range tests {
	for i, tc := range tests {
		gotPaths, gotTitle := gido.ParsePrefixedTitle(tc.in)
		if !reflect.DeepEqual(gotPaths, tc.wantPaths) {
			t.Errorf("got paths: %q, want: %q", gotPaths, tc.wantPaths)
			t.Errorf("%d: got paths: %q, want: %q", i, gotPaths, tc.wantPaths)
		}
		if gotTitle != tc.wantTitle {
			t.Errorf("%d: got title: %q, want: %q", i, gotTitle, tc.wantTitle)
		}
	}
}

func TestParsePrefixedChangeTitle(t *testing.T) {
	tests := []struct {
		inRoot    string
		in        string
		wantPaths []string
		wantTitle string
	}{
		{
			in:        "import/path: Change title.",
			wantPaths: []string{"import/path"}, wantTitle: "Change title.",
		},
		{
			inRoot:    "root",
			in:        "import/path: Change title.",
			wantPaths: []string{"root/import/path"}, wantTitle: "Change title.",
		},
		{ // Multiple comma-separated paths.
			in:        "path1, path2: Change title.",
			wantPaths: []string{"path1", "path2"}, wantTitle: "Change title.",
		},
		{
			inRoot:    "root",
			in:        "path1, path2: Change title.",
			wantPaths: []string{"root/path1", "root/path2"}, wantTitle: "Change title.",
		},
		{ // No path prefix.
			in:        "Change title.",
			wantPaths: []string{""}, wantTitle: "Change title.",
		},
		{
			inRoot:    "root",
			in:        "Change title.",
			wantPaths: []string{"root"}, wantTitle: "Change title.",
		},
	}
	for i, tc := range tests {
		gotPaths, gotTitle := gido.ParsePrefixedChangeTitle(tc.inRoot, tc.in)
		if !reflect.DeepEqual(gotPaths, tc.wantPaths) {
			t.Errorf("%d: got paths: %q, want: %q", i, gotPaths, tc.wantPaths)
		}
		if gotTitle != tc.wantTitle {
			t.Errorf("got title: %q, want: %q", gotTitle, tc.wantTitle)
			t.Errorf("%d: got title: %q, want: %q", i, gotTitle, tc.wantTitle)
		}
	}
}

func TestImportPathToFullPrefix(t *testing.T) {
util.go
@@ -4,10 +4,11 @@ import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"net/url"
	"os"
	"time"

	"github.com/shurcooL/httperror"
	"github.com/shurcooL/users"
@@ -141,5 +142,20 @@ func (rw *responseWriterBytes) Write(p []byte) (n int, err error) {
	if len(p) > 0 {
		rw.WroteBytes = true
	}
	return rw.ResponseWriter.Write(p)
}

// stripPrefix returns request r with prefix of length prefixLen stripped from r.URL.Path.
// prefixLen must not be longer than len(r.URL.Path), otherwise stripPrefix panics.
// If r.URL.Path is empty after the prefix is stripped, the path is changed to "/".
func stripPrefix(r *http.Request, prefixLen int) *http.Request {
	r2 := new(http.Request)
	*r2 = *r
	r2.URL = new(url.URL)
	*r2.URL = *r.URL
	r2.URL.Path = r.URL.Path[prefixLen:]
	if r2.URL.Path == "" {
		r2.URL.Path = "/"
	}
	return r2
}