@@ -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}
@@ -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), }
@@ -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) } }
@@ -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} }
@@ -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) } }
@@ -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 }
@@ -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" }
@@ -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), } }
@@ -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) {
@@ -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 }