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 1 year 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
+}