Mirror of @tangled.org/core. Running on a Raspberry Pi Zero 2 (Please be gentle).

implement issues and comments

authored by anirudh.fi and committed by

GitHub 95c4e51c c7793cda

+875 -153
+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 ); ··· 60 59 create table if not exists follows ( 61 60 user_did text not null, 62 61 subject_did text not null, 62 + at_uri text not null unique, 63 63 rkey text not null, 64 64 followed_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 65 65 primary key (user_did, subject_did), 66 66 check (user_did <> subject_did) 67 67 ); 68 + create table if not exists issues ( 69 + id integer primary key autoincrement, 70 + owner_did text not null, 71 + repo_at text not null, 72 + issue_id integer not null unique, 73 + title text not null, 74 + body text not null, 75 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 76 + unique(repo_at, issue_id), 77 + foreign key (repo_at) references repos(at_uri) on delete cascade 78 + ); 79 + create table if not exists comments ( 80 + id integer primary key autoincrement, 81 + owner_did text not null, 82 + issue_id integer not null, 83 + repo_at text not null, 84 + comment_id integer not null, 85 + body text not null, 86 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 87 + unique(issue_id, comment_id), 88 + foreign key (issue_id) references issues(issue_id) on delete cascade 89 + ); 68 90 create table if not exists _jetstream ( 69 91 id integer primary key autoincrement, 70 92 last_time_us integer not null 71 93 ); 94 + 95 + create table if not exists repo_issue_seqs ( 96 + repo_at text primary key, 97 + next_issue_id integer not null default 1 98 + ); 99 + 72 100 `) 73 101 if err != nil { 74 102 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) { ··· 67 66 func (d *DB) GetRepo(did, name string) (*Repo, error) { 68 67 var repo Repo 69 68 70 - row := d.db.QueryRow(`select did, name, knot, created from repos where did = ? and name = ?`, did, name) 69 + row := d.db.QueryRow(`select did, name, knot, created, at_uri from repos where did = ? and name = ?`, did, name) 71 70 72 71 var createdAt string 73 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt); err != nil { 72 + if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri); err != nil { 74 73 return nil, err 75 74 } 76 75 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 80 79 } 81 80 82 81 func (d *DB) AddRepo(repo *Repo) error { 83 - _, err := d.db.Exec(`insert into repos (did, name, knot, rkey) values (?, ?, ?, ?)`, repo.Did, repo.Name, repo.Knot, repo.Rkey) 82 + _, err := d.db.Exec(`insert into repos (did, name, knot, rkey, at_uri) values (?, ?, ?, ?, ?)`, repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri) 84 83 return err 85 84 } 86 85
+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 ··· 430 427 func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error { 431 428 params.Active = "settings" 432 429 return p.executeRepo("repo/settings", w, params) 430 + } 431 + 432 + type RepoIssuesParams struct { 433 + LoggedInUser *auth.User 434 + RepoInfo RepoInfo 435 + Active string 436 + Issues []db.Issue 437 + } 438 + 439 + func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { 440 + params.Active = "issues" 441 + return p.executeRepo("repo/issues", w, params) 442 + } 443 + 444 + type RepoSingleIssueParams struct { 445 + LoggedInUser *auth.User 446 + RepoInfo RepoInfo 447 + Active string 448 + Issue db.Issue 449 + Comments []db.Comment 450 + IssueOwnerHandle string 451 + 452 + State string 453 + } 454 + 455 + func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 456 + params.Active = "issues" 457 + if params.Issue.Open { 458 + params.State = "open" 459 + } else { 460 + params.State = "closed" 461 + } 462 + return p.execute("repo/issue", w, params) 463 + } 464 + 465 + type RepoNewIssueParams struct { 466 + LoggedInUser *auth.User 467 + RepoInfo RepoInfo 468 + Active string 469 + } 470 + 471 + func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 472 + params.Active = "issues" 473 + return p.executeRepo("repo/new-issue", w, params) 433 474 } 434 475 435 476 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 }}
+171 -145
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> 42 8 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 }} 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> 65 46 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> 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> 82 63 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 64 + <time class="text-xs text-gray-500" 65 + >{{ timeFmt .LastCommit.Author.When }}</time 66 + > 67 + </div> 68 + </div> 69 + {{ end }} 70 + {{ end }} 71 + 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 ··· 111 103 before:left-[-2.2px] 112 104 before:top-1/2 113 105 before:-translate-y-1/2 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 href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" class="inline no-underline hover:underline">{{ index $messageParts 0 }}</a> 122 - {{ if gt (len $messageParts) 1 }} 123 - 124 - <button class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded" 125 - hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')"> 126 - <i class="w-3 h-3" data-lucide="ellipsis"></i> 127 - </button> 128 - {{ end }} 129 - </div> 130 - {{ if gt (len $messageParts) 1 }} 131 - <p class="hidden mt-1 text-sm cursor-text pb-2">{{ nl2br (unwrapText (index $messageParts 1)) }}</p> 132 - {{ end }} 133 - </div> 134 - </div> 135 - </div> 136 - 137 - <div class="text-xs text-gray-500"> 138 - <span class="font-mono"> 139 - <a 140 - href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" 141 - class="text-gray-500 no-underline hover:underline" 142 - >{{ slice .Hash.String 0 8 }}</a 143 - > 144 - </span> 145 - <span class="mx-2 before:content-['·'] before:select-none"></span> 146 - <span> 147 - <a 148 - href="mailto:{{ .Author.Email }}" 149 - class="text-gray-500 no-underline hover:underline" 150 - >{{ .Author.Name }}</a 106 + " 151 107 > 152 - </span> 153 - <div class="inline-block px-1 select-none after:content-['·']"></div> 154 - <span>{{ timeFmt .Author.When }}</span> 155 - </div> 156 - </div> 157 - {{ end }} 158 - </div> 159 - </div> 160 - {{- end -}} 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 114 + href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" 115 + class="inline no-underline hover:underline" 116 + >{{ index $messageParts 0 }}</a 117 + > 118 + {{ if gt (len $messageParts) 1 }} 161 119 162 - </main> 120 + <button 121 + class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded" 122 + hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')" 123 + > 124 + <i 125 + class="w-3 h-3" 126 + data-lucide="ellipsis" 127 + ></i> 128 + </button> 129 + {{ end }} 130 + </div> 131 + {{ if gt (len $messageParts) 1 }} 132 + <p 133 + class="hidden mt-1 text-sm cursor-text pb-2" 134 + > 135 + {{ nl2br (unwrapText (index $messageParts 1)) }} 136 + </p> 137 + {{ end }} 138 + </div> 139 + </div> 140 + </div> 141 + 142 + <div class="text-xs text-gray-500"> 143 + <span class="font-mono"> 144 + <a 145 + href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" 146 + class="text-gray-500 no-underline hover:underline" 147 + >{{ slice .Hash.String 0 8 }}</a 148 + > 149 + </span> 150 + <span 151 + class="mx-2 before:content-['·'] before:select-none" 152 + ></span> 153 + <span> 154 + <a 155 + href="mailto:{{ .Author.Email }}" 156 + class="text-gray-500 no-underline hover:underline" 157 + >{{ .Author.Name }}</a 158 + > 159 + </span> 160 + <div 161 + class="inline-block px-1 select-none after:content-['·']" 162 + ></div> 163 + <span>{{ timeFmt .Author.When }}</span> 164 + </div> 165 + </div> 166 + {{ end }} 167 + </div> 168 + </div> 169 + </main> 163 170 {{ end }} 164 171 165 172 {{ define "repoAfter" }} 166 - {{- if .Readme }} 167 - <section class="mt-4 p-6 border border-black w-full mx-auto"> 168 - <article class="readme"> 169 - {{- .Readme -}} 170 - </article> 171 - </section> 172 - {{- end -}} 173 + {{- if .Readme }} 174 + <section class="mt-4 p-6 border border-black w-full mx-auto"> 175 + <article class="readme"> 176 + {{- .Readme -}} 177 + </article> 178 + </section> 179 + {{- end -}} 173 180 174 - <section class="mt-4 p-6 border border-black w-full mx-auto"> 175 - <strong>clone</strong> 176 - <pre> git clone https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }} </pre> 177 - </section> 181 + 182 + <section class="mt-4 p-6 border border-black w-full mx-auto"> 183 + <strong>clone</strong> 184 + <pre> 185 + git clone https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }} </pre 186 + > 187 + </section> 178 188 {{ 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 ) ··· 444 441 Knot string 445 442 OwnerId identity.Identity 446 443 RepoName string 444 + RepoAt string 447 445 } 448 446 449 447 func (f *FullyResolvedRepo) OwnerDid() string { ··· 504 500 return collaborators, nil 505 501 } 506 502 503 + func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 504 + user := s.auth.GetUser(r) 505 + f, err := fullyResolvedRepo(r) 506 + if err != nil { 507 + log.Println("failed to get repo and knot", err) 508 + return 509 + } 510 + 511 + issueId := chi.URLParam(r, "issue") 512 + issueIdInt, err := strconv.Atoi(issueId) 513 + if err != nil { 514 + http.Error(w, "bad issue id", http.StatusBadRequest) 515 + log.Println("failed to parse issue id", err) 516 + return 517 + } 518 + 519 + issue, comments, err := s.db.GetIssueWithComments(f.RepoAt, issueIdInt) 520 + if err != nil { 521 + log.Println("failed to get issue and comments", err) 522 + s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 523 + return 524 + } 525 + 526 + issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid) 527 + if err != nil { 528 + log.Println("failed to resolve issue owner", err) 529 + } 530 + 531 + s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 532 + LoggedInUser: user, 533 + RepoInfo: pages.RepoInfo{ 534 + OwnerDid: f.OwnerDid(), 535 + OwnerHandle: f.OwnerHandle(), 536 + Name: f.RepoName, 537 + SettingsAllowed: settingsAllowed(s, user, f), 538 + }, 539 + Issue: *issue, 540 + Comments: comments, 541 + 542 + IssueOwnerHandle: issueOwnerIdent.Handle.String(), 543 + }) 544 + 545 + } 546 + 547 + func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { 548 + user := s.auth.GetUser(r) 549 + f, err := fullyResolvedRepo(r) 550 + if err != nil { 551 + log.Println("failed to get repo and knot", err) 552 + return 553 + } 554 + 555 + issueId := chi.URLParam(r, "issue") 556 + issueIdInt, err := strconv.Atoi(issueId) 557 + if err != nil { 558 + http.Error(w, "bad issue id", http.StatusBadRequest) 559 + log.Println("failed to parse issue id", err) 560 + return 561 + } 562 + 563 + if user.Did == f.OwnerDid() { 564 + err := s.db.CloseIssue(f.RepoAt, issueIdInt) 565 + if err != nil { 566 + log.Println("failed to close issue", err) 567 + s.pages.Notice(w, "issues", "Failed to close issue. Try again later.") 568 + return 569 + } 570 + s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 571 + return 572 + } else { 573 + log.Println("user is not the owner of the repo") 574 + http.Error(w, "for biden", http.StatusUnauthorized) 575 + return 576 + } 577 + } 578 + 579 + func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { 580 + user := s.auth.GetUser(r) 581 + f, err := fullyResolvedRepo(r) 582 + if err != nil { 583 + log.Println("failed to get repo and knot", err) 584 + return 585 + } 586 + 587 + issueId := chi.URLParam(r, "issue") 588 + issueIdInt, err := strconv.Atoi(issueId) 589 + if err != nil { 590 + http.Error(w, "bad issue id", http.StatusBadRequest) 591 + log.Println("failed to parse issue id", err) 592 + return 593 + } 594 + 595 + if user.Did == f.OwnerDid() { 596 + err := s.db.ReopenIssue(f.RepoAt, issueIdInt) 597 + if err != nil { 598 + log.Println("failed to reopen issue", err) 599 + s.pages.Notice(w, "issues", "Failed to reopen issue. Try again later.") 600 + return 601 + } 602 + s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 603 + return 604 + } else { 605 + log.Println("user is not the owner of the repo") 606 + http.Error(w, "forbidden", http.StatusUnauthorized) 607 + return 608 + } 609 + } 610 + 611 + func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { 612 + user := s.auth.GetUser(r) 613 + f, err := fullyResolvedRepo(r) 614 + if err != nil { 615 + log.Println("failed to get repo and knot", err) 616 + return 617 + } 618 + 619 + issueId := chi.URLParam(r, "issue") 620 + issueIdInt, err := strconv.Atoi(issueId) 621 + if err != nil { 622 + http.Error(w, "bad issue id", http.StatusBadRequest) 623 + log.Println("failed to parse issue id", err) 624 + return 625 + } 626 + 627 + switch r.Method { 628 + case http.MethodPost: 629 + body := r.FormValue("body") 630 + if body == "" { 631 + s.pages.Notice(w, "issue", "Body is required") 632 + return 633 + } 634 + 635 + commentId := rand.IntN(1000000) 636 + fmt.Println(commentId) 637 + fmt.Println("comment id", commentId) 638 + 639 + err := s.db.NewComment(&db.Comment{ 640 + OwnerDid: user.Did, 641 + RepoAt: f.RepoAt, 642 + Issue: issueIdInt, 643 + CommentId: commentId, 644 + Body: body, 645 + }) 646 + if err != nil { 647 + log.Println("failed to create comment", err) 648 + s.pages.Notice(w, "issue-comment", "Failed to create comment.") 649 + return 650 + } 651 + 652 + s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 653 + return 654 + } 655 + } 656 + 657 + func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { 658 + user := s.auth.GetUser(r) 659 + f, err := fullyResolvedRepo(r) 660 + if err != nil { 661 + log.Println("failed to get repo and knot", err) 662 + return 663 + } 664 + 665 + issues, err := s.db.GetIssues(f.RepoAt) 666 + if err != nil { 667 + log.Println("failed to get issues", err) 668 + s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 669 + return 670 + } 671 + 672 + s.pages.RepoIssues(w, pages.RepoIssuesParams{ 673 + LoggedInUser: s.auth.GetUser(r), 674 + RepoInfo: pages.RepoInfo{ 675 + OwnerDid: f.OwnerDid(), 676 + OwnerHandle: f.OwnerHandle(), 677 + Name: f.RepoName, 678 + SettingsAllowed: settingsAllowed(s, user, f), 679 + }, 680 + Issues: issues, 681 + }) 682 + return 683 + } 684 + 685 + func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { 686 + user := s.auth.GetUser(r) 687 + 688 + f, err := fullyResolvedRepo(r) 689 + if err != nil { 690 + log.Println("failed to get repo and knot", err) 691 + return 692 + } 693 + 694 + switch r.Method { 695 + case http.MethodGet: 696 + s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 697 + LoggedInUser: user, 698 + RepoInfo: pages.RepoInfo{ 699 + Name: f.RepoName, 700 + OwnerDid: f.OwnerDid(), 701 + OwnerHandle: f.OwnerHandle(), 702 + SettingsAllowed: settingsAllowed(s, user, f), 703 + }, 704 + }) 705 + case http.MethodPost: 706 + title := r.FormValue("title") 707 + body := r.FormValue("body") 708 + 709 + if title == "" || body == "" { 710 + s.pages.Notice(w, "issue", "Title and body are required") 711 + return 712 + } 713 + 714 + issueId, err := s.db.NewIssue(&db.Issue{ 715 + RepoAt: f.RepoAt, 716 + Title: title, 717 + Body: body, 718 + OwnerDid: user.Did, 719 + }) 720 + if err != nil { 721 + log.Println("failed to create issue", err) 722 + s.pages.Notice(w, "issue", "Failed to create issue.") 723 + return 724 + } 725 + 726 + s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 727 + return 728 + } 729 + } 730 + 507 731 func fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) { 508 732 repoName := chi.URLParam(r, "repo") 509 733 knot, ok := r.Context().Value("knot").(string) ··· 745 513 return nil, fmt.Errorf("malformed middleware") 746 514 } 747 515 516 + repoAt, ok := r.Context().Value("repoAt").(string) 517 + if !ok { 518 + log.Println("malformed middleware") 519 + return nil, fmt.Errorf("malformed middleware") 520 + } 521 + 748 522 return &FullyResolvedRepo{ 749 523 Knot: knot, 750 524 OwnerId: id, 751 525 RepoName: repoName, 526 + RepoAt: repoAt, 752 527 }, nil 753 528 } 754 529
+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 { ··· 596 597 } 597 598 log.Println("created repo record: ", atresp.Uri) 598 599 600 + repo.AtUri = atresp.Uri 601 + 599 602 err = s.db.AddRepo(repo) 600 603 if err != nil { 601 604 log.Println(err) ··· 802 801 r.Get("/branches", s.RepoBranches) 803 802 r.Get("/tags", s.RepoTags) 804 803 r.Get("/blob/{ref}/*", s.RepoBlob) 804 + 805 + r.Route("/issues", func(r chi.Router) { 806 + r.Get("/", s.RepoIssues) 807 + r.Get("/{issue}", s.RepoSingleIssue) 808 + r.Get("/new", s.NewIssue) 809 + r.Post("/new", s.NewIssue) 810 + r.Post("/{issue}/comment", s.IssueComment) 811 + r.Post("/{issue}/close", s.CloseIssue) 812 + r.Post("/{issue}/reopen", s.ReopenIssue) 813 + }) 805 814 806 815 // These routes get proxied to the knot 807 816 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=