Monorepo for Tangled tangled.org
856
fork

Configure Feed

Select the types of activity you want to include in your feed.

implement issues and comments

authored by

Anirudh Oppiliappan and committed by
GitHub
743a4283 ea4d1b4e

+872 -150
+30
appview/db/db.go
··· 47 47 name text not null, 48 48 knot text not null, 49 49 rkey text not null, 50 + at_uri text not null unique, 50 51 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 51 52 unique(did, name, knot, rkey) 52 53 ); ··· 59 60 create table if not exists follows ( 60 61 user_did text not null, 61 62 subject_did text not null, 63 + at_uri text not null unique, 62 64 rkey text not null, 63 65 followed_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 64 66 primary key (user_did, subject_did), 65 67 check (user_did <> subject_did) 66 68 ); 69 + create table if not exists issues ( 70 + id integer primary key autoincrement, 71 + owner_did text not null, 72 + repo_at text not null, 73 + issue_id integer not null unique, 74 + title text not null, 75 + body text not null, 76 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 77 + unique(repo_at, issue_id), 78 + foreign key (repo_at) references repos(at_uri) on delete cascade 79 + ); 80 + create table if not exists comments ( 81 + id integer primary key autoincrement, 82 + owner_did text not null, 83 + issue_id integer not null, 84 + repo_at text not null, 85 + comment_id integer not null, 86 + body text not null, 87 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 88 + unique(issue_id, comment_id), 89 + foreign key (issue_id) references issues(issue_id) on delete cascade 90 + ); 67 91 create table if not exists _jetstream ( 68 92 id integer primary key autoincrement, 69 93 last_time_us integer not null 70 94 ); 95 + 96 + create table if not exists repo_issue_seqs ( 97 + repo_at text primary key, 98 + next_issue_id integer not null default 1 99 + ); 100 + 71 101 `) 72 102 if err != nil { 73 103 return nil, err
+179
appview/db/issues.go
··· 1 + package db 2 + 3 + import "time" 4 + 5 + type Issue struct { 6 + RepoAt string 7 + OwnerDid string 8 + IssueId int 9 + Created *time.Time 10 + Title string 11 + Body string 12 + Open bool 13 + } 14 + 15 + type Comment struct { 16 + OwnerDid string 17 + RepoAt string 18 + Issue int 19 + CommentId int 20 + Body string 21 + Created *time.Time 22 + } 23 + 24 + func (d *DB) NewIssue(issue *Issue) (int, error) { 25 + tx, err := d.db.Begin() 26 + if err != nil { 27 + return 0, err 28 + } 29 + defer tx.Rollback() 30 + 31 + _, err = tx.Exec(` 32 + insert or ignore into repo_issue_seqs (repo_at, next_issue_id) 33 + values (?, 1) 34 + `, issue.RepoAt) 35 + if err != nil { 36 + return 0, err 37 + } 38 + 39 + var nextId int 40 + err = tx.QueryRow(` 41 + update repo_issue_seqs 42 + set next_issue_id = next_issue_id + 1 43 + where repo_at = ? 44 + returning next_issue_id - 1 45 + `, issue.RepoAt).Scan(&nextId) 46 + if err != nil { 47 + return 0, err 48 + } 49 + 50 + issue.IssueId = nextId 51 + 52 + _, err = tx.Exec(` 53 + insert into issues (repo_at, owner_did, issue_id, title, body) 54 + values (?, ?, ?, ?, ?) 55 + `, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body) 56 + if err != nil { 57 + return 0, err 58 + } 59 + 60 + if err := tx.Commit(); err != nil { 61 + return 0, err 62 + } 63 + 64 + return nextId, nil 65 + } 66 + 67 + func (d *DB) GetIssues(repoAt string) ([]Issue, error) { 68 + var issues []Issue 69 + 70 + rows, err := d.db.Query(`select owner_did, issue_id, created, title, body, open from issues where repo_at = ?`, repoAt) 71 + if err != nil { 72 + return nil, err 73 + } 74 + defer rows.Close() 75 + 76 + for rows.Next() { 77 + var issue Issue 78 + var createdAt string 79 + err := rows.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open) 80 + if err != nil { 81 + return nil, err 82 + } 83 + 84 + createdTime, err := time.Parse(time.RFC3339, createdAt) 85 + if err != nil { 86 + return nil, err 87 + } 88 + issue.Created = &createdTime 89 + 90 + issues = append(issues, issue) 91 + } 92 + 93 + if err := rows.Err(); err != nil { 94 + return nil, err 95 + } 96 + 97 + return issues, nil 98 + } 99 + 100 + func (d *DB) GetIssueWithComments(repoAt string, issueId int) (*Issue, []Comment, error) { 101 + query := `select owner_did, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?` 102 + row := d.db.QueryRow(query, repoAt, issueId) 103 + 104 + var issue Issue 105 + var createdAt string 106 + err := row.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open) 107 + if err != nil { 108 + return nil, nil, err 109 + } 110 + 111 + createdTime, err := time.Parse(time.RFC3339, createdAt) 112 + if err != nil { 113 + return nil, nil, err 114 + } 115 + issue.Created = &createdTime 116 + 117 + comments, err := d.GetComments(repoAt, issueId) 118 + if err != nil { 119 + return nil, nil, err 120 + } 121 + 122 + return &issue, comments, nil 123 + } 124 + 125 + func (d *DB) NewComment(comment *Comment) error { 126 + query := `insert into comments (owner_did, repo_at, issue_id, comment_id, body) values (?, ?, ?, ?, ?)` 127 + _, err := d.db.Exec( 128 + query, 129 + comment.OwnerDid, 130 + comment.RepoAt, 131 + comment.Issue, 132 + comment.CommentId, 133 + comment.Body, 134 + ) 135 + return err 136 + } 137 + 138 + func (d *DB) GetComments(repoAt string, issueId int) ([]Comment, error) { 139 + var comments []Comment 140 + 141 + rows, err := d.db.Query(`select owner_did, issue_id, comment_id, body, created from comments where repo_at = ? and issue_id = ? order by created asc`, repoAt, issueId) 142 + if err != nil { 143 + return nil, err 144 + } 145 + defer rows.Close() 146 + 147 + for rows.Next() { 148 + var comment Comment 149 + var createdAt string 150 + err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &comment.Body, &createdAt) 151 + if err != nil { 152 + return nil, err 153 + } 154 + 155 + createdAtTime, err := time.Parse(time.RFC3339, createdAt) 156 + if err != nil { 157 + return nil, err 158 + } 159 + comment.Created = &createdAtTime 160 + 161 + comments = append(comments, comment) 162 + } 163 + 164 + if err := rows.Err(); err != nil { 165 + return nil, err 166 + } 167 + 168 + return comments, nil 169 + } 170 + 171 + func (d *DB) CloseIssue(repoAt string, issueId int) error { 172 + _, err := d.db.Exec(`update issues set open = 0 where repo_at = ? and issue_id = ?`, repoAt, issueId) 173 + return err 174 + } 175 + 176 + func (d *DB) ReopenIssue(repoAt string, issueId int) error { 177 + _, err := d.db.Exec(`update issues set open = 1 where repo_at = ? and issue_id = ?`, repoAt, issueId) 178 + return err 179 + }
+4 -3
appview/db/repos.go
··· 11 11 Knot string 12 12 Rkey string 13 13 Created time.Time 14 + AtUri string 14 15 } 15 16 16 17 func (d *DB) GetAllRepos() ([]Repo, error) { ··· 66 67 func (d *DB) GetRepo(did, name string) (*Repo, error) { 67 68 var repo Repo 68 69 69 - row := d.db.QueryRow(`select did, name, knot, created from repos where did = ? and name = ?`, did, name) 70 + row := d.db.QueryRow(`select did, name, knot, created, at_uri from repos where did = ? and name = ?`, did, name) 70 71 71 72 var createdAt string 72 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt); err != nil { 73 + if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri); err != nil { 73 74 return nil, err 74 75 } 75 76 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 79 80 } 80 81 81 82 func (d *DB) AddRepo(repo *Repo) error { 82 - _, err := d.db.Exec(`insert into repos (did, name, knot, rkey) values (?, ?, ?, ?)`, repo.Did, repo.Name, repo.Knot, repo.Rkey) 83 + _, err := d.db.Exec(`insert into repos (did, name, knot, rkey, at_uri) values (?, ?, ?, ?, ?)`, repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri) 83 84 return err 84 85 } 85 86
+47
appview/pages/pages.go
··· 283 283 284 284 func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error { 285 285 params.Active = "overview" 286 + if params.IsEmpty { 287 + return p.executeRepo("repo/empty", w, params) 288 + } 286 289 return p.executeRepo("repo/index", w, params) 287 290 } 288 291 ··· 427 430 func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error { 428 431 params.Active = "settings" 429 432 return p.executeRepo("repo/settings", w, params) 433 + } 434 + 435 + type RepoIssuesParams struct { 436 + LoggedInUser *auth.User 437 + RepoInfo RepoInfo 438 + Active string 439 + Issues []db.Issue 440 + } 441 + 442 + func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { 443 + params.Active = "issues" 444 + return p.executeRepo("repo/issues", w, params) 445 + } 446 + 447 + type RepoSingleIssueParams struct { 448 + LoggedInUser *auth.User 449 + RepoInfo RepoInfo 450 + Active string 451 + Issue db.Issue 452 + Comments []db.Comment 453 + IssueOwnerHandle string 454 + 455 + State string 456 + } 457 + 458 + func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 459 + params.Active = "issues" 460 + if params.Issue.Open { 461 + params.State = "open" 462 + } else { 463 + params.State = "closed" 464 + } 465 + return p.execute("repo/issue", w, params) 466 + } 467 + 468 + type RepoNewIssueParams struct { 469 + LoggedInUser *auth.User 470 + RepoInfo RepoInfo 471 + Active string 472 + } 473 + 474 + func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 475 + params.Active = "issues" 476 + return p.executeRepo("repo/new-issue", w, params) 430 477 } 431 478 432 479 func (p *Pages) Static() http.Handler {
+4 -4
appview/pages/templates/repo/empty.html
··· 1 - {{ define "title" }}{{ .RepoInfo.OwnerWithAt }} / {{ .RepoInfo.Name }}{{ end }} 2 - 3 - {{ define "content" }} 1 + {{ define "repoContent" }} 4 2 <main> 5 - <p>This is an empty Git repository. Push some commits here.</p> 3 + <p class="text-center pt-5 text-gray-400"> 4 + This is an empty repository. Push some commits here. 5 + </p> 6 6 </main> 7 7 {{ end }}
+168 -142
appview/pages/templates/repo/index.html
··· 1 1 {{ define "repoContent" }} 2 - <main> 3 - {{- if .IsEmpty }} 4 - this repo is empty 5 - {{ else }} 6 - <div class="flex gap-4"> 7 - <div id="file-tree" class="w-3/5"> 8 - {{ $containerstyle := "py-1" }} 9 - {{ $linkstyle := "no-underline hover:underline" }} 2 + <main> 3 + <div class="flex gap-4"> 4 + <div id="file-tree" class="w-3/5"> 5 + {{ $containerstyle := "py-1" }} 6 + {{ $linkstyle := "no-underline hover:underline" }} 10 7 11 - <div class="flex justify-end"> 12 - <select 13 - onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + this.value" 14 - class="p-1 border border-gray-500 bg-white" 15 - > 16 - <optgroup label="branches" class="bold text-sm"> 17 - {{ range .Branches }} 18 - <option 19 - value="{{ .Reference.Name }}" 20 - class="py-1" 21 - {{if eq .Reference.Name $.Ref}}selected{{end}} 22 - > 23 - {{ .Reference.Name }} 24 - </option> 25 - {{ end }} 26 - </optgroup> 27 - <optgroup label="tags" class="bold text-sm"> 28 - {{ range .Tags }} 29 - <option 30 - value="{{ .Reference.Name }}" 31 - class="py-1" 32 - {{if eq .Reference.Name $.Ref}}selected{{end}} 33 - > 34 - {{ .Reference.Name }} 35 - </option> 36 - {{ else }} 37 - <option class="py-1" disabled>no tags found</option> 38 - {{ end }} 39 - </optgroup> 40 - </select> 41 - </div> 8 + 9 + <div class="flex justify-end"> 10 + <select 11 + onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + this.value" 12 + class="p-1 border border-gray-500 bg-white" 13 + > 14 + <optgroup label="branches" class="bold text-sm"> 15 + {{ range .Branches }} 16 + <option 17 + value="{{ .Reference.Name }}" 18 + class="py-1" 19 + {{ if eq .Reference.Name $.Ref }} 20 + selected 21 + {{ end }} 22 + > 23 + {{ .Reference.Name }} 24 + </option> 25 + {{ end }} 26 + </optgroup> 27 + <optgroup label="tags" class="bold text-sm"> 28 + {{ range .Tags }} 29 + <option 30 + value="{{ .Reference.Name }}" 31 + class="py-1" 32 + {{ if eq .Reference.Name $.Ref }} 33 + selected 34 + {{ end }} 35 + > 36 + {{ .Reference.Name }} 37 + </option> 38 + {{ else }} 39 + <option class="py-1" disabled> 40 + no tags found 41 + </option> 42 + {{ end }} 43 + </optgroup> 44 + </select> 45 + </div> 42 46 43 - {{ range .Files }} 44 - {{ if not .IsFile }} 45 - <div class="{{ $containerstyle }}"> 46 - <div class="flex justify-between items-center"> 47 - <a 48 - href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref }}/{{ .Name }}" 49 - class="{{ $linkstyle }}" 50 - > 51 - <div class="flex items-center gap-2"> 52 - <i 53 - class="w-3 h-3 fill-current" 54 - data-lucide="folder" 55 - ></i 56 - >{{ .Name }} 57 - </div> 58 - </a> 59 - 60 - <time class="text-xs text-gray-500">{{ timeFmt .LastCommit.Author.When }}</time> 61 - </div> 62 - </div> 63 - {{ end }} 64 - {{ end }} 47 + {{ range .Files }} 48 + {{ if not .IsFile }} 49 + <div class="{{ $containerstyle }}"> 50 + <div class="flex justify-between items-center"> 51 + <a 52 + href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref }}/{{ .Name }}" 53 + class="{{ $linkstyle }}" 54 + > 55 + <div class="flex items-center gap-2"> 56 + <i 57 + class="w-3 h-3 fill-current" 58 + data-lucide="folder" 59 + ></i 60 + >{{ .Name }} 61 + </div> 62 + </a> 65 63 66 - {{ range .Files }} 67 - {{ if .IsFile }} 68 - <div class="{{ $containerstyle }}"> 69 - <div class="flex justify-between items-center"> 70 - <a 71 - href="/{{ $.RepoInfo.FullName }}/blob/{{ $.Ref }}/{{ .Name }}" 72 - class="{{ $linkstyle }}" 73 - > 74 - <div class="flex items-center gap-2"> 75 - <i 76 - class="w-3 h-3" 77 - data-lucide="file" 78 - ></i 79 - >{{ .Name }} 80 - </div> 81 - </a> 64 + <time class="text-xs text-gray-500" 65 + >{{ timeFmt .LastCommit.Author.When }}</time 66 + > 67 + </div> 68 + </div> 69 + {{ end }} 70 + {{ end }} 82 71 83 - <time class="text-xs text-gray-500">{{ timeFmt .LastCommit.Author.When }}</time> 84 - </div> 85 - </div> 86 - {{ end }} 87 - {{ end }} 88 - </div> 89 - <div id="commit-log" class="flex-1"> 90 - {{ range .Commits }} 91 - <div 92 - class="relative 72 + {{ range .Files }} 73 + {{ if .IsFile }} 74 + <div class="{{ $containerstyle }}"> 75 + <div class="flex justify-between items-center"> 76 + <a 77 + href="/{{ $.RepoInfo.FullName }}/blob/{{ $.Ref }}/{{ .Name }}" 78 + class="{{ $linkstyle }}" 79 + > 80 + <div class="flex items-center gap-2"> 81 + <i 82 + class="w-3 h-3" 83 + data-lucide="file" 84 + ></i 85 + >{{ .Name }} 86 + </div> 87 + </a> 88 + 89 + <time class="text-xs text-gray-500" 90 + >{{ timeFmt .LastCommit.Author.When }}</time 91 + > 92 + </div> 93 + </div> 94 + {{ end }} 95 + {{ end }} 96 + </div> 97 + <div id="commit-log" class="flex-1"> 98 + {{ range .Commits }} 99 + <div 100 + class="relative 93 101 px-4 94 102 py-4 95 103 border-l ··· 103 111 before:left-[-2.2px] 104 112 before:top-1/2 105 113 before:-translate-y-1/2 106 - "> 114 + " 115 + > 116 + <div id="commit-message"> 117 + {{ $messageParts := splitN .Message "\n\n" 2 }} 118 + <div class="text-base cursor-pointer"> 119 + <div> 120 + <div> 121 + <a 122 + href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" 123 + class="inline no-underline hover:underline" 124 + >{{ index $messageParts 0 }}</a 125 + > 126 + {{ if gt (len $messageParts) 1 }} 107 127 108 - <div id="commit-message"> 109 - {{ $messageParts := splitN .Message "\n\n" 2 }} 110 - <div class="text-base cursor-pointer"> 111 - <div> 112 - <div> 113 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" class="inline no-underline hover:underline">{{ index $messageParts 0 }}</a> 114 - {{ if gt (len $messageParts) 1 }} 128 + <button 129 + class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded" 130 + hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')" 131 + > 132 + <i 133 + class="w-3 h-3" 134 + data-lucide="ellipsis" 135 + ></i> 136 + </button> 137 + {{ end }} 138 + </div> 139 + {{ if gt (len $messageParts) 1 }} 140 + <p 141 + class="hidden mt-1 text-sm cursor-text pb-2" 142 + > 143 + {{ nl2br (unwrapText (index $messageParts 1)) }} 144 + </p> 145 + {{ end }} 146 + </div> 147 + </div> 148 + </div> 115 149 116 - <button class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded" 117 - hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')"> 118 - <i class="w-3 h-3" data-lucide="ellipsis"></i> 119 - </button> 150 + <div class="text-xs text-gray-500"> 151 + <span class="font-mono"> 152 + <a 153 + href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" 154 + class="text-gray-500 no-underline hover:underline" 155 + >{{ slice .Hash.String 0 8 }}</a 156 + > 157 + </span> 158 + <span 159 + class="mx-2 before:content-['·'] before:select-none" 160 + ></span> 161 + <span> 162 + <a 163 + href="mailto:{{ .Author.Email }}" 164 + class="text-gray-500 no-underline hover:underline" 165 + >{{ .Author.Name }}</a 166 + > 167 + </span> 168 + <div 169 + class="inline-block px-1 select-none after:content-['·']" 170 + ></div> 171 + <span>{{ timeFmt .Author.When }}</span> 172 + </div> 173 + </div> 120 174 {{ end }} 121 - </div> 122 - {{ if gt (len $messageParts) 1 }} 123 - <p class="hidden mt-1 text-sm cursor-text pb-2">{{ nl2br (unwrapText (index $messageParts 1)) }}</p> 124 - {{ end }} 125 175 </div> 126 - </div> 127 176 </div> 128 - 129 - <div class="text-xs text-gray-500"> 130 - <span class="font-mono"> 131 - <a 132 - href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" 133 - class="text-gray-500 no-underline hover:underline" 134 - >{{ slice .Hash.String 0 8 }}</a 135 - > 136 - </span> 137 - <span class="mx-2 before:content-['·'] before:select-none"></span> 138 - <span> 139 - <a 140 - href="mailto:{{ .Author.Email }}" 141 - class="text-gray-500 no-underline hover:underline" 142 - >{{ .Author.Name }}</a 143 - > 144 - </span> 145 - <div class="inline-block px-1 select-none after:content-['·']"></div> 146 - <span>{{ timeFmt .Author.When }}</span> 147 - </div> 148 - </div> 149 - {{ end }} 150 - </div> 151 - </div> 152 - {{- end -}} 153 - 154 - </main> 177 + </main> 155 178 {{ end }} 156 179 157 180 {{ define "repoAfter" }} 158 - {{- if .Readme }} 159 - <section class="mt-4 p-6 border border-black w-full mx-auto"> 160 - <article class="readme"> 161 - {{- .Readme -}} 162 - </article> 163 - </section> 164 - {{- end -}} 181 + {{- if .Readme }} 182 + <section class="mt-4 p-6 border border-black w-full mx-auto"> 183 + <article class="readme"> 184 + {{- .Readme -}} 185 + </article> 186 + </section> 187 + {{- end -}} 165 188 166 - <section class="mt-4 p-6 border border-black w-full mx-auto"> 167 - <strong>clone</strong> 168 - <pre> git clone https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }} </pre> 169 - </section> 189 + 190 + <section class="mt-4 p-6 border border-black w-full mx-auto"> 191 + <strong>clone</strong> 192 + <pre> 193 + git clone https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }} </pre 194 + > 195 + </section> 170 196 {{ end }}
+107
appview/pages/templates/repo/issue.html
··· 1 + {{ define "title" }} 2 + {{ .Issue.Title }} &middot; 3 + {{ .RepoInfo.FullName }} 4 + {{ end }} 5 + 6 + {{ define "repoContent" }} 7 + <div class="flex items-center justify-between"> 8 + <h1> 9 + {{ .Issue.Title }} 10 + <span class="text-gray-400">#{{ .Issue.IssueId }}</span> 11 + </h1> 12 + 13 + <time class="text-sm">{{ .Issue.Created | timeFmt }}</time> 14 + </div> 15 + 16 + {{ $bgColor := "bg-gray-800" }} 17 + {{ $icon := "ban" }} 18 + {{ if eq .State "open" }} 19 + {{ $bgColor = "bg-green-600" }} 20 + {{ $icon = "circle-dot" }} 21 + {{ end }} 22 + 23 + 24 + <section class="m-2"> 25 + <div class="flex items-center gap-2"> 26 + <div 27 + id="state" 28 + class="inline-flex items-center px-3 py-1 {{ $bgColor }}" 29 + > 30 + <i 31 + data-lucide="{{ $icon }}" 32 + class="w-4 h-4 mr-1.5 text-white" 33 + ></i> 34 + <span class="text-white">{{ .State }}</span> 35 + </div> 36 + <span class="text-gray-400 text-sm"> 37 + opened by 38 + {{ didOrHandle .Issue.OwnerDid .IssueOwnerHandle }} 39 + </span> 40 + </div> 41 + 42 + {{ if .Issue.Body }} 43 + <article id="body" class="mt-8"> 44 + {{ .Issue.Body | escapeHtml }} 45 + </article> 46 + {{ end }} 47 + </section> 48 + 49 + <section id="comments" class="mt-8 space-y-4"> 50 + {{ range .Comments }} 51 + <div 52 + id="comment-{{ .CommentId }}" 53 + class="border border-gray-200 p-4" 54 + > 55 + <div class="flex items-center gap-2 mb-2"> 56 + <span class="text-gray-400 text-sm"> 57 + {{ .OwnerDid }} 58 + </span> 59 + <span class="text-gray-500 text-sm"> 60 + {{ .Created | timeFmt }} 61 + </span> 62 + </div> 63 + <div class=""> 64 + {{ nl2br .Body }} 65 + </div> 66 + </div> 67 + {{ end }} 68 + </section> 69 + 70 + {{ if .LoggedInUser }} 71 + <form 72 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 73 + class="mt-8" 74 + > 75 + <textarea 76 + name="body" 77 + class="w-full p-2 border border-gray-200" 78 + placeholder="Add to the discussion..." 79 + ></textarea> 80 + <button type="submit" class="btn mt-2">comment</button> 81 + <div id="issue-comment"></div> 82 + </form> 83 + {{ end }} 84 + 85 + {{ if eq .LoggedInUser.Did .Issue.OwnerDid }} 86 + {{ $action := "close" }} 87 + {{ $icon := "circle-x" }} 88 + {{ $hoverColor := "red" }} 89 + {{ if eq .State "closed" }} 90 + {{ $action = "reopen" }} 91 + {{ $icon = "circle-dot" }} 92 + {{ $hoverColor = "green" }} 93 + {{ end }} 94 + <form 95 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/{{ $action }}" 96 + class="mt-8" 97 + > 98 + <button type="submit" class="btn hover:bg-{{ $hoverColor }}-300"> 99 + <i 100 + data-lucide="{{ $icon }}" 101 + class="w-4 h-4 mr-2 text-{{ $hoverColor }}-400" 102 + ></i> 103 + <span class="text-black">{{ $action }}</span> 104 + </button> 105 + </form> 106 + {{ end }} 107 + {{ end }}
+48
appview/pages/templates/repo/issues.html
··· 1 + {{ define "title" }}issues | {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "repoContent" }} 4 + <div class="flex justify-between items-center"> 5 + <h1 class="m-0">issues</h1> 6 + <div class="error" id="issues"></div> 7 + <a 8 + href="/{{ .RepoInfo.FullName }}/issues/new" 9 + class="btn flex items-center gap-2 no-underline" 10 + > 11 + <i data-lucide="square-plus" class="w-5 h-5"></i> 12 + <span>new issue</span> 13 + </a> 14 + </div> 15 + 16 + <section id="issues" class="mt-8 space-y-4"> 17 + {{ range .Issues }} 18 + <div class="border border-gray-200 p-4"> 19 + <time class="float-right text-sm"> 20 + {{ .Created | timeFmt }} 21 + </time> 22 + <div class="flex items-center gap-2 py-2"> 23 + {{ if .Open }} 24 + <i 25 + data-lucide="circle-dot" 26 + class="w-4 h-4 text-green-600" 27 + ></i> 28 + {{ else }} 29 + <i data-lucide="ban" class="w-4 h-4 text-red-600"></i> 30 + {{ end }} 31 + <a 32 + href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 33 + class="no-underline hover:underline" 34 + > 35 + {{ .Title }} 36 + </a> 37 + </div> 38 + <div class="text-sm flex gap-2 text-gray-400"> 39 + <span>#{{ .IssueId }}</span> 40 + <span class="before:content-['·']"> 41 + opened by 42 + {{ .OwnerDid }} 43 + </span> 44 + </div> 45 + </div> 46 + {{ end }} 47 + </section> 48 + {{ end }}
+30
appview/pages/templates/repo/new-issue.html
··· 1 + {{ define "title" }}new issue | {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "repoContent" }} 4 + <form 5 + hx-post="/{{ .RepoInfo.FullName }}/issues/new" 6 + class="mt-6 space-y-6" 7 + hx-swap="none" 8 + > 9 + <div class="flex flex-col gap-4"> 10 + <div> 11 + <label for="title">title</label> 12 + <input type="text" name="title" id="title" class="w-full" /> 13 + </div> 14 + <div> 15 + <label for="body">body</label> 16 + <textarea 17 + name="body" 18 + id="body" 19 + rows="6" 20 + class="w-full resize-y" 21 + placeholder="Describe your issue." 22 + ></textarea> 23 + </div> 24 + <div> 25 + <button type="submit" class="btn">create</button> 26 + </div> 27 + </div> 28 + <div id="issues" class="error"></div> 29 + </form> 30 + {{ end }}
+1
appview/state/middleware.go
··· 187 187 } 188 188 189 189 ctx := context.WithValue(req.Context(), "knot", repo.Knot) 190 + ctx = context.WithValue(ctx, "repoAt", repo.AtUri) 190 191 next.ServeHTTP(w, req.WithContext(ctx)) 191 192 }) 192 193 }
+239
appview/state/repo.go
··· 6 6 "fmt" 7 7 "io" 8 8 "log" 9 + "math/rand/v2" 9 10 "net/http" 10 11 "path" 12 + "strconv" 11 13 "strings" 12 14 13 15 "github.com/bluesky-social/indigo/atproto/identity" 14 16 securejoin "github.com/cyphar/filepath-securejoin" 15 17 "github.com/go-chi/chi/v5" 16 18 "github.com/sotangled/tangled/appview/auth" 19 + "github.com/sotangled/tangled/appview/db" 17 20 "github.com/sotangled/tangled/appview/pages" 18 21 "github.com/sotangled/tangled/types" 19 22 ) ··· 441 444 Knot string 442 445 OwnerId identity.Identity 443 446 RepoName string 447 + RepoAt string 444 448 } 445 449 446 450 func (f *FullyResolvedRepo) OwnerDid() string { ··· 500 504 return collaborators, nil 501 505 } 502 506 507 + func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 508 + user := s.auth.GetUser(r) 509 + f, err := fullyResolvedRepo(r) 510 + if err != nil { 511 + log.Println("failed to get repo and knot", err) 512 + return 513 + } 514 + 515 + issueId := chi.URLParam(r, "issue") 516 + issueIdInt, err := strconv.Atoi(issueId) 517 + if err != nil { 518 + http.Error(w, "bad issue id", http.StatusBadRequest) 519 + log.Println("failed to parse issue id", err) 520 + return 521 + } 522 + 523 + issue, comments, err := s.db.GetIssueWithComments(f.RepoAt, issueIdInt) 524 + if err != nil { 525 + log.Println("failed to get issue and comments", err) 526 + s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 527 + return 528 + } 529 + 530 + issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid) 531 + if err != nil { 532 + log.Println("failed to resolve issue owner", err) 533 + } 534 + 535 + s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 536 + LoggedInUser: user, 537 + RepoInfo: pages.RepoInfo{ 538 + OwnerDid: f.OwnerDid(), 539 + OwnerHandle: f.OwnerHandle(), 540 + Name: f.RepoName, 541 + SettingsAllowed: settingsAllowed(s, user, f), 542 + }, 543 + Issue: *issue, 544 + Comments: comments, 545 + 546 + IssueOwnerHandle: issueOwnerIdent.Handle.String(), 547 + }) 548 + 549 + } 550 + 551 + func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { 552 + user := s.auth.GetUser(r) 553 + f, err := fullyResolvedRepo(r) 554 + if err != nil { 555 + log.Println("failed to get repo and knot", err) 556 + return 557 + } 558 + 559 + issueId := chi.URLParam(r, "issue") 560 + issueIdInt, err := strconv.Atoi(issueId) 561 + if err != nil { 562 + http.Error(w, "bad issue id", http.StatusBadRequest) 563 + log.Println("failed to parse issue id", err) 564 + return 565 + } 566 + 567 + if user.Did == f.OwnerDid() { 568 + err := s.db.CloseIssue(f.RepoAt, issueIdInt) 569 + if err != nil { 570 + log.Println("failed to close issue", err) 571 + s.pages.Notice(w, "issues", "Failed to close issue. Try again later.") 572 + return 573 + } 574 + s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 575 + return 576 + } else { 577 + log.Println("user is not the owner of the repo") 578 + http.Error(w, "for biden", http.StatusUnauthorized) 579 + return 580 + } 581 + } 582 + 583 + func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { 584 + user := s.auth.GetUser(r) 585 + f, err := fullyResolvedRepo(r) 586 + if err != nil { 587 + log.Println("failed to get repo and knot", err) 588 + return 589 + } 590 + 591 + issueId := chi.URLParam(r, "issue") 592 + issueIdInt, err := strconv.Atoi(issueId) 593 + if err != nil { 594 + http.Error(w, "bad issue id", http.StatusBadRequest) 595 + log.Println("failed to parse issue id", err) 596 + return 597 + } 598 + 599 + if user.Did == f.OwnerDid() { 600 + err := s.db.ReopenIssue(f.RepoAt, issueIdInt) 601 + if err != nil { 602 + log.Println("failed to reopen issue", err) 603 + s.pages.Notice(w, "issues", "Failed to reopen issue. Try again later.") 604 + return 605 + } 606 + s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 607 + return 608 + } else { 609 + log.Println("user is not the owner of the repo") 610 + http.Error(w, "forbidden", http.StatusUnauthorized) 611 + return 612 + } 613 + } 614 + 615 + func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { 616 + user := s.auth.GetUser(r) 617 + f, err := fullyResolvedRepo(r) 618 + if err != nil { 619 + log.Println("failed to get repo and knot", err) 620 + return 621 + } 622 + 623 + issueId := chi.URLParam(r, "issue") 624 + issueIdInt, err := strconv.Atoi(issueId) 625 + if err != nil { 626 + http.Error(w, "bad issue id", http.StatusBadRequest) 627 + log.Println("failed to parse issue id", err) 628 + return 629 + } 630 + 631 + switch r.Method { 632 + case http.MethodPost: 633 + body := r.FormValue("body") 634 + if body == "" { 635 + s.pages.Notice(w, "issue", "Body is required") 636 + return 637 + } 638 + 639 + commentId := rand.IntN(1000000) 640 + fmt.Println(commentId) 641 + fmt.Println("comment id", commentId) 642 + 643 + err := s.db.NewComment(&db.Comment{ 644 + OwnerDid: user.Did, 645 + RepoAt: f.RepoAt, 646 + Issue: issueIdInt, 647 + CommentId: commentId, 648 + Body: body, 649 + }) 650 + if err != nil { 651 + log.Println("failed to create comment", err) 652 + s.pages.Notice(w, "issue-comment", "Failed to create comment.") 653 + return 654 + } 655 + 656 + s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 657 + return 658 + } 659 + } 660 + 661 + func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { 662 + user := s.auth.GetUser(r) 663 + f, err := fullyResolvedRepo(r) 664 + if err != nil { 665 + log.Println("failed to get repo and knot", err) 666 + return 667 + } 668 + 669 + issues, err := s.db.GetIssues(f.RepoAt) 670 + if err != nil { 671 + log.Println("failed to get issues", err) 672 + s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 673 + return 674 + } 675 + 676 + s.pages.RepoIssues(w, pages.RepoIssuesParams{ 677 + LoggedInUser: s.auth.GetUser(r), 678 + RepoInfo: pages.RepoInfo{ 679 + OwnerDid: f.OwnerDid(), 680 + OwnerHandle: f.OwnerHandle(), 681 + Name: f.RepoName, 682 + SettingsAllowed: settingsAllowed(s, user, f), 683 + }, 684 + Issues: issues, 685 + }) 686 + return 687 + } 688 + 689 + func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { 690 + user := s.auth.GetUser(r) 691 + 692 + f, err := fullyResolvedRepo(r) 693 + if err != nil { 694 + log.Println("failed to get repo and knot", err) 695 + return 696 + } 697 + 698 + switch r.Method { 699 + case http.MethodGet: 700 + s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 701 + LoggedInUser: user, 702 + RepoInfo: pages.RepoInfo{ 703 + Name: f.RepoName, 704 + OwnerDid: f.OwnerDid(), 705 + OwnerHandle: f.OwnerHandle(), 706 + SettingsAllowed: settingsAllowed(s, user, f), 707 + }, 708 + }) 709 + case http.MethodPost: 710 + title := r.FormValue("title") 711 + body := r.FormValue("body") 712 + 713 + if title == "" || body == "" { 714 + s.pages.Notice(w, "issue", "Title and body are required") 715 + return 716 + } 717 + 718 + issueId, err := s.db.NewIssue(&db.Issue{ 719 + RepoAt: f.RepoAt, 720 + Title: title, 721 + Body: body, 722 + OwnerDid: user.Did, 723 + }) 724 + if err != nil { 725 + log.Println("failed to create issue", err) 726 + s.pages.Notice(w, "issue", "Failed to create issue.") 727 + return 728 + } 729 + 730 + s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 731 + return 732 + } 733 + } 734 + 503 735 func fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) { 504 736 repoName := chi.URLParam(r, "repo") 505 737 knot, ok := r.Context().Value("knot").(string) ··· 513 745 return nil, fmt.Errorf("malformed middleware") 514 746 } 515 747 748 + repoAt, ok := r.Context().Value("repoAt").(string) 749 + if !ok { 750 + log.Println("malformed middleware") 751 + return nil, fmt.Errorf("malformed middleware") 752 + } 753 + 516 754 return &FullyResolvedRepo{ 517 755 Knot: knot, 518 756 OwnerId: id, 519 757 RepoName: repoName, 758 + RepoAt: repoAt, 520 759 }, nil 521 760 } 522 761
+12 -1
appview/state/state.go
··· 70 70 } 71 71 72 72 did := e.Did 73 - fmt.Println("got event", e.Commit.Collection, e.Commit.RKey, e.Commit.Record) 74 73 raw := json.RawMessage(e.Commit.Record) 75 74 76 75 switch e.Commit.Collection { ··· 597 596 } 598 597 log.Println("created repo record: ", atresp.Uri) 599 598 599 + repo.AtUri = atresp.Uri 600 + 600 601 err = s.db.AddRepo(repo) 601 602 if err != nil { 602 603 log.Println(err) ··· 801 802 r.Get("/branches", s.RepoBranches) 802 803 r.Get("/tags", s.RepoTags) 803 804 r.Get("/blob/{ref}/*", s.RepoBlob) 805 + 806 + r.Route("/issues", func(r chi.Router) { 807 + r.Get("/", s.RepoIssues) 808 + r.Get("/{issue}", s.RepoSingleIssue) 809 + r.Get("/new", s.NewIssue) 810 + r.Post("/new", s.NewIssue) 811 + r.Post("/{issue}/comment", s.IssueComment) 812 + r.Post("/{issue}/close", s.CloseIssue) 813 + r.Post("/{issue}/reopen", s.ReopenIssue) 814 + }) 804 815 805 816 // These routes get proxied to the knot 806 817 r.Get("/info/refs", s.InfoRefs)
+1
go.mod
··· 24 24 github.com/russross/blackfriday/v2 v2.1.0 25 25 github.com/sethvargo/go-envconfig v1.1.0 26 26 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e 27 + golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 27 28 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 28 29 ) 29 30
+2
go.sum
··· 307 307 golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= 308 308 golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= 309 309 golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 310 + golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 h1:LoYXNGAShUG3m/ehNk4iFctuhGX/+R1ZpfJ4/ia80JM= 311 + golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= 310 312 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 311 313 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 312 314 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=