Commit 230fe608ef1516dbdc57be322b43aa7d33813625
Initial commit
Charles Lehner committed on 3/25/2016, 11:51:02 PMFiles changed
README.md | added |
index.js | added |
lib/schemas.js | added |
package.json | added |
test.js | added |
README.md | ||
---|---|---|
@@ -1,0 +1,174 @@ | ||
1 … | +# ssb-issues | |
2 … | + | |
3 … | +Issue tracking built on secure-scuttlebutt | |
4 … | + | |
5 … | +## Schema | |
6 … | + | |
7 … | +#### type: issue | |
8 … | + | |
9 … | +An issue. Represents something that should be fixed. | |
10 … | + | |
11 … | +```js | |
12 … | +{ | |
13 … | + type: 'issue', | |
14 … | + project: Link?, | |
15 … | + title: string?, | |
16 … | + text: string? | |
17 … | +} | |
18 … | +``` | |
19 … | + | |
20 … | +#### type: issue-edit | |
21 … | + | |
22 … | +An edit to an issue. Only considered valid if its author is the author of the | |
23 … | +issue (`issue.author`) or of the project of the issue (`issue.projectAuthor`). | |
24 … | + | |
25 … | +```js | |
26 … | +{ | |
27 … | + type: 'issue-edit', | |
28 … | + issue: IssueRef, | |
29 … | + open: boolean?, | |
30 … | + title: string? | |
31 … | +} | |
32 … | +``` | |
33 … | + | |
34 … | +## API | |
35 … | + | |
36 … | +```js | |
37 … | +var Issues = require('ssb-issues') | |
38 … | +var issues = Issues.init(sbot) | |
39 … | +``` | |
40 … | + | |
41 … | +#### get: async | |
42 … | + | |
43 … | +Get an issue by its id | |
44 … | + | |
45 … | +```js | |
46 … | +issues.get(issueId, cb) | |
47 … | +``` | |
48 … | + | |
49 … | +The resulting issue object is as follows: | |
50 … | + | |
51 … | +```js | |
52 … | +{ | |
53 … | + id: MsgRef, | |
54 … | + author: FeedRef, | |
55 … | + project: Ref?, | |
56 … | + projectAuthor: FeedRef?, | |
57 … | + created_at: number, | |
58 … | + updated_at: number, | |
59 … | + open: boolean | |
60 … | +} | |
61 … | +``` | |
62 … | + | |
63 … | +- `id`: id of the issue | |
64 … | +- `author`: author of the issue | |
65 … | +- `created_at` (timestamp): when the issue was created | |
66 … | +- `updated_at` (timestamp): when the issue was last updated | |
67 … | +- `title`: title of the issue | |
68 … | +- `open`: whether the issue is open (true) or closed (false) | |
69 … | +- `project`: the project that the issue is for | |
70 … | +- `projectAuthor`: the author of the project | |
71 … | + | |
72 … | +#### createFeedStream: source | |
73 … | + | |
74 … | +Get a stream of issues | |
75 … | + | |
76 … | +```js | |
77 … | +issues.createFeedStream({ project:, open:, live:, gt:, gte:, lt:, lte:, reverse:, limit:, }, cb) | |
78 … | +``` | |
79 … | + | |
80 … | + - `project` (Ref): get only issues for the given target | |
81 … | + - `open` (boolean): get only open or closed issues | |
82 … | + - `author` (FeedRef): get only issues from the given feed | |
83 … | + - `live` (boolean, default: `false`): Keep the stream open and emit new messages as they are received. | |
84 … | + - `gt` (greater than), `gte` (greater than or equal): maximum `[timestamp, id]` | |
85 … | + - `lt` (less than), `lte` (less than or equal): minimum `[timestamp, id]` | |
86 … | + - `reverse` (boolean, default: `false`): reverse the order of results | |
87 … | + | |
88 … | +#### new: async | |
89 … | + | |
90 … | +Create a new issue | |
91 … | + | |
92 … | +```js | |
93 … | +issues.new({ project:, title:, text: }, cb) | |
94 … | +``` | |
95 … | + | |
96 … | +- `project` (Ref): id of an ssb object representing the target of the issue | |
97 … | +- `title` (string): title of the issue | |
98 … | +- `text` (string): text describing the issue | |
99 … | + | |
100 … | +#### close: async | |
101 … | + | |
102 … | +```js | |
103 … | +issues.close(id, cb) | |
104 … | +``` | |
105 … | + | |
106 … | +Mark an issue as closed. | |
107 … | + | |
108 … | +`id` (MsgRef): id of the issue to reopen | |
109 … | + | |
110 … | +#### reopen: async | |
111 … | + | |
112 … | +```js | |
113 … | +issues.reopen(id, cb)` | |
114 … | +``` | |
115 … | + | |
116 … | +Mark an issue as open. | |
117 … | + | |
118 … | +`id` (MsgRef): id of the issue to reopen | |
119 … | +`text` (string): text to accompany the open action | |
120 … | + | |
121 … | +#### edit: async | |
122 … | + | |
123 … | +```js | |
124 … | +issues.edit(id, opts, cb)` | |
125 … | +``` | |
126 … | + | |
127 … | +Edit an issue. | |
128 … | + | |
129 … | +`id` (MsgRef): id of the issue to reopen | |
130 … | +`opts.open` (boolean): set open/closed status | |
131 … | +`opts.title` (string): set title | |
132 … | + | |
133 … | +### schemas | |
134 … | + | |
135 … | +```js | |
136 … | +var issueSchemas = Issues.schemas | |
137 … | +``` | |
138 … | + | |
139 … | +#### `issueSchemas.new(project, title, text)` | |
140 … | + | |
141 … | +Create a new issue. | |
142 … | + | |
143 … | +- `project` (Ref): id of project to associate the issue with | |
144 … | +- `title` (string): title to give the issue | |
145 … | +- `text` (string): text body for the issue | |
146 … | + | |
147 … | +#### `issueSchemas.edit(id, opts)` | |
148 … | + | |
149 … | +Edit an issue. | |
150 … | + | |
151 … | +- `opts.open` (boolean): open or close the issue | |
152 … | +- `opts.title` (string): set the title of the issue | |
153 … | + | |
154 … | +#### `issueSchemas.close(id)` | |
155 … | + | |
156 … | +Close an issue. | |
157 … | + | |
158 … | +- `id` (MsgRef): id of an issue to mark as closed | |
159 … | + | |
160 … | +#### `issueSchemas.reopen(id)` | |
161 … | + | |
162 … | +Reopen an issue. | |
163 … | + | |
164 … | +- `id` (MsgRef): id of an issue to mark as open | |
165 … | + | |
166 … | +## License | |
167 … | + | |
168 … | +Copyright (c) 2016 Charles Lehner | |
169 … | + | |
170 … | +Usage of the works is permitted provided that this instrument is | |
171 … | +retained with the works, so that any entity that uses the works is | |
172 … | +notified of this instrument. | |
173 … | + | |
174 … | +DISCLAIMER: THE WORKS ARE WITHOUT WARRANTY. |
index.js | |||
---|---|---|---|
@@ -1,0 +1,182 @@ | |||
1 … | +var pull = require('pull-stream') | ||
2 … | +var paramap = require('pull-paramap') | ||
3 … | +var asyncMemo = require('asyncmemo') | ||
4 … | +var issueSchemas = require('./lib/schemas') | ||
5 … | + | ||
6 … | +function Cache(fn, ssb) { | ||
7 … | + return asyncMemo(fn) | ||
8 … | + return function (key, cb) { ac.get(key, cb) } | ||
9 … | +} | ||
10 … | + | ||
11 … | +function isUpdateValid(issue, msg) { | ||
12 … | + return msg.value.author == issue.author | ||
13 … | + && msg.value.author == issue.projectAuthor | ||
14 … | +} | ||
15 … | + | ||
16 … | +exports.name = 'issues' | ||
17 … | + | ||
18 … | +exports.manifest = { | ||
19 … | + get: 'async', | ||
20 … | + createFeedStream: 'source', | ||
21 … | + new: 'async', | ||
22 … | + edit: 'async', | ||
23 … | + close: 'async', | ||
24 … | + reopen: 'async' | ||
25 … | +} | ||
26 … | + | ||
27 … | +exports.schemas = issueSchemas | ||
28 … | + | ||
29 … | +exports.init = function (ssb) { | ||
30 … | + | ||
31 … | + var ssbGet = asyncMemo(ssb.get) | ||
32 … | + | ||
33 … | + var getIssue = asyncMemo(function (id, cb) { | ||
34 … | + var issue = {} | ||
35 … | + var issueMsg | ||
36 … | + | ||
37 … | + if (id && id.value && id.key) { | ||
38 … | + var msg = id | ||
39 … | + id = id.key | ||
40 … | + gotIssueMsg(null, msg) | ||
41 … | + } else { | ||
42 … | + ssbGet(id, gotIssueMsg) | ||
43 … | + } | ||
44 … | + | ||
45 … | + function gotIssueMsg(err, msg) { | ||
46 … | + if (err) return cb(err) | ||
47 … | + issueMsg = msg | ||
48 … | + issue.id = msg.key | ||
49 … | + issue.author = msg.value.author | ||
50 … | + var c = msg.value.content | ||
51 … | + issue.project = c.project | ||
52 … | + issue.text = c.text | ||
53 … | + issue.created_at = issue.updated_at = msg.value.timestamp | ||
54 … | + if (c.project) | ||
55 … | + ssbGet(c.project, gotProjectMsg) | ||
56 … | + else | ||
57 … | + getLinks() | ||
58 … | + } | ||
59 … | + | ||
60 … | + function gotProjectMsg(err, msg) { | ||
61 … | + if (err) return cb(err) | ||
62 … | + issue.projectAuthor = msg.author | ||
63 … | + getLinks() | ||
64 … | + } | ||
65 … | + | ||
66 … | + function getLinks() { | ||
67 … | + var now = Date.now() | ||
68 … | + // compute the result from the past data | ||
69 … | + pull( | ||
70 … | + ssb.links({dest: id, lt: now, reverse: true}), | ||
71 … | + pull.drain(onOldMsg, onOldEnd) | ||
72 … | + ) | ||
73 … | + // keep the results up-to-date in the future | ||
74 … | + pull( | ||
75 … | + ssb.links({dest: id, gte: now, values: true, live: true}), | ||
76 … | + pull.drain(onNewMsg, onNewEnd) | ||
77 … | + ) | ||
78 … | + } | ||
79 … | + | ||
80 … | + function onOldMsg(msg) { | ||
81 … | + if (!isUpdateValid(issue, msg)) | ||
82 … | + return | ||
83 … | + var c = msg.value.content | ||
84 … | + if (c.open != null && issue.open == null) | ||
85 … | + issue.open = c.open | ||
86 … | + if (c.title != null && issue.title == null) | ||
87 … | + issue.title = c.title | ||
88 … | + if (msg.value.timestamp > issue.updated_at) | ||
89 … | + issue.updated_at = msg.value.timestamp | ||
90 … | + checkReady() | ||
91 … | + } | ||
92 … | + | ||
93 … | + function onNewMsg(msg) { | ||
94 … | + if (!isUpdateValid(issue, msg)) | ||
95 … | + return | ||
96 … | + var c = msg.value.content | ||
97 … | + if (c.open != null) | ||
98 … | + issue.open = c.open | ||
99 … | + if (c.title != null) | ||
100 … | + issue.title = c.title | ||
101 … | + if (msg.value.timestamp > issue.updated_at) | ||
102 … | + issue.updated_at = msg.value.timestamp | ||
103 … | + } | ||
104 … | + | ||
105 … | + function checkReady() { | ||
106 … | + // call back once all the issue properties are set | ||
107 … | + if (issue.open != null && issue.title != null) { | ||
108 … | + var _cb = cb | ||
109 … | + delete cb | ||
110 … | + _cb(null, issue) | ||
111 … | + } | ||
112 … | + } | ||
113 … | + | ||
114 … | + function onOldEnd(err) { | ||
115 … | + if (err) { | ||
116 … | + if (cb) cb(err) | ||
117 … | + else console.error(err) | ||
118 … | + return | ||
119 … | + } | ||
120 … | + // process the root message last | ||
121 … | + onOldMsg(issueMsg) | ||
122 … | + // if callback hasn't been called yet, the issue is missing a field | ||
123 … | + if (cb) { | ||
124 … | + if (issue.open == null) | ||
125 … | + issue.open = true | ||
126 … | + if (issue.title == null) | ||
127 … | + issue.title = issue.id | ||
128 … | + checkReady() | ||
129 … | + } | ||
130 … | + } | ||
131 … | + | ||
132 … | + function onNewEnd(err) { | ||
133 … | + if (err) { | ||
134 … | + if (cb) cb(err) | ||
135 … | + else console.error(err) | ||
136 … | + } | ||
137 … | + } | ||
138 … | + }) | ||
139 … | + | ||
140 … | + function createFeedStream(opts) { | ||
141 … | + opts.type = 'issue' | ||
142 … | + delete opts.limit | ||
143 … | + return pull( | ||
144 … | + // TODO: use links2 for this | ||
145 … | + ssb.messagesByType(opts), | ||
146 … | + pull.filter(function (msg) { | ||
147 … | + return (!opts.project || opts.project == msg.value.content.project) | ||
148 … | + && (!opts.author || opts.author == msg.value.author) | ||
149 … | + }), | ||
150 … | + paramap(getIssue, 8) | ||
151 … | + ) | ||
152 … | + } | ||
153 … | + | ||
154 … | + function editIssue(id, opts, cb) { | ||
155 … | + ssb.publish(issueSchemas.edit(id, opts), cb) | ||
156 … | + } | ||
157 … | + | ||
158 … | + function closeIssue(id, cb) { | ||
159 … | + ssb.publish(issueSchemas.close(id), cb) | ||
160 … | + } | ||
161 … | + | ||
162 … | + function reopenIssue(id, cb) { | ||
163 … | + ssb.publish(issueSchemas.reopen(id), cb) | ||
164 … | + } | ||
165 … | + | ||
166 … | + function newIssue(opts, cb) { | ||
167 … | + var msg = issueSchemas.new(opts.project, opts.title, opts.text) | ||
168 … | + ssb.publish(msg, function (err, msg) { | ||
169 … | + if (err) return cb(err) | ||
170 … | + getIssue(msg, cb) | ||
171 … | + }) | ||
172 … | + } | ||
173 … | + | ||
174 … | + return { | ||
175 … | + get: getIssue, | ||
176 … | + createFeedStream: createFeedStream, | ||
177 … | + new: newIssue, | ||
178 … | + edit: editIssue, | ||
179 … | + close: closeIssue, | ||
180 … | + reopen: reopenIssue | ||
181 … | + } | ||
182 … | +} |
lib/schemas.js | ||
---|---|---|
@@ -1,0 +1,43 @@ | ||
1 … | +var ssbRef = require('ssb-ref') | |
2 … | +var mlib = require('ssb-msgs') | |
3 … | + | |
4 … | +exports.new = function (project, title, text) { | |
5 … | + if (!ssbRef.isLink(project)) | |
6 … | + throw new Error('invalid project id') | |
7 … | + var msg = { type: 'issue', project: project } | |
8 … | + if (title) { | |
9 … | + if (typeof title === 'string') | |
10 … | + msg.title = title | |
11 … | + else | |
12 … | + throw new Error('invalid issue title') | |
13 … | + } | |
14 … | + if (text) { | |
15 … | + if (typeof text === 'string') | |
16 … | + msg.text = text | |
17 … | + else | |
18 … | + throw new Error('invalid issue text') | |
19 … | + } | |
20 … | + return msg | |
21 … | +} | |
22 … | + | |
23 … | +exports.edit = function (id, opts) { | |
24 … | + if (!ssbRef.isMsg(id)) | |
25 … | + throw new Error('invalid issue id') | |
26 … | + var msg = { | |
27 … | + type: 'issue-edit', | |
28 … | + issue: id | |
29 … | + } | |
30 … | + if (opts.open != null) | |
31 … | + msg.open = opts.open | |
32 … | + if (opts.title != null) | |
33 … | + msg.title = opts.title | |
34 … | + return msg | |
35 … | +} | |
36 … | + | |
37 … | +exports.close = function (id) { | |
38 … | + return exports.edit(id, {open: false}) | |
39 … | +} | |
40 … | + | |
41 … | +exports.reopen = function (id) { | |
42 … | + return exports.edit(id, {open: true}) | |
43 … | +} |
package.json | ||
---|---|---|
@@ -1,0 +1,27 @@ | ||
1 … | +{ | |
2 … | + "name": "ssb-issues", | |
3 … | + "version": "0.0.0", | |
4 … | + "description": "Issue tracking built on secure-scuttlebutt", | |
5 … | + "main": "index.js", | |
6 … | + "author": "Charles Lehner (http://celehner.com/)", | |
7 … | + "license": "Fair", | |
8 … | + "homepage": "http://git-ssb.celehner.com/", | |
9 … | + "scripts": { | |
10 … | + "test": "node test" | |
11 … | + }, | |
12 … | + "dependencies": { | |
13 … | + "asyncmemo": "^0.1.0", | |
14 … | + "pull-cat": "^1.1.8", | |
15 … | + "pull-paramap": "^1.1.2", | |
16 … | + "pull-stream": "^3.2.0", | |
17 … | + "ssb-client": "^3.0.1", | |
18 … | + "ssb-links": "^1.0.1", | |
19 … | + "ssb-msgs": "^5.2.0", | |
20 … | + "ssb-ref": "^2.3.0" | |
21 … | + }, | |
22 … | + "devDependencies": { | |
23 … | + "scuttlebot": "^7.6.6", | |
24 … | + "ssb-keys": "^5.0.0", | |
25 … | + "tape": "^4.5.1" | |
26 … | + } | |
27 … | +} |
test.js | ||
---|---|---|
@@ -1,0 +1,99 @@ | ||
1 … | +var test = require('tape') | |
2 … | +var Issues = require('.') | |
3 … | +var ssbKeys = require('ssb-keys') | |
4 … | +var pull = require('pull-stream') | |
5 … | + | |
6 … | +function awaitMsg(sbot, msg, cb) { | |
7 … | + sbot.createUserStream({ | |
8 … | + id: msg.value.author, | |
9 … | + gte: msg.value.sequence, | |
10 … | + live: true, | |
11 … | + limit: 1 | |
12 … | + })(null, cb) | |
13 … | +} | |
14 … | + | |
15 … | +var createSbot = require('scuttlebot') | |
16 … | + .use(require('scuttlebot/plugins/master')) | |
17 … | + .use(require('scuttlebot/plugins/blobs')) | |
18 … | + | |
19 … | +var sbot = createSbot({ | |
20 … | + temp: 'test-ssb-issues', timeout: 200, | |
21 … | + allowPrivate: true, | |
22 … | + keys: ssbKeys.generate() | |
23 … | +}) | |
24 … | + | |
25 … | +var issues = Issues.init(sbot) | |
26 … | + | |
27 … | +test.onFinish(function () { | |
28 … | + sbot.close(true) | |
29 … | +}) | |
30 … | + | |
31 … | +var projectId | |
32 … | + | |
33 … | +test('create project', function (t) { | |
34 … | + sbot.publish({type: 'foo'}, function (err, msg) { | |
35 … | + t.error(err, 'publish') | |
36 … | + projectId = msg.key | |
37 … | + t.end() | |
38 … | + }) | |
39 … | +}) | |
40 … | + | |
41 … | +var issue1 | |
42 … | + | |
43 … | +test('create an issue', function (t) { | |
44 … | + var title = 'Test Title' | |
45 … | + issues.new({ | |
46 … | + project: projectId, | |
47 … | + title: title, | |
48 … | + text: 'Test Text' | |
49 … | + }, function (err, issue) { | |
50 … | + t.error(err, 'new issue') | |
51 … | + t.ok(issue.id, 'id') | |
52 … | + t.equals(issue.project, projectId, 'project') | |
53 … | + t.equals(issue.author, sbot.id, 'author') | |
54 … | + t.equals(issue.title, title, 'title') | |
55 … | + t.equals(issue.open, true, 'open') | |
56 … | + t.equals(~~(issue.created_at/1e5), ~~(Date.now()/1e5), 'created_at') | |
57 … | + issue1 = issue | |
58 … | + t.end() | |
59 … | + }) | |
60 … | +}) | |
61 … | + | |
62 … | +test('update the issue', function (t) { | |
63 … | + var title = 'New Title' | |
64 … | + issues.edit(issue1.id, {title: title}, function (err, msg) { | |
65 … | + t.error(err, 'edit') | |
66 … | + t.ok(msg, 'msg') | |
67 … | + awaitMsg(sbot, msg, function (err) { | |
68 … | + t.error(err, 'await') | |
69 … | + t.equals(issue1.title, title, 'new title') | |
70 … | + t.notEquals(issue1.updated_at, issue1.created_at, 'updated_at is new') | |
71 … | + t.ok(Date.now() - issue1.updated_at < 60e3, 'updated_at is recent') | |
72 … | + t.end() | |
73 … | + }) | |
74 … | + }) | |
75 … | +}) | |
76 … | + | |
77 … | +test('close the issue', function (t) { | |
78 … | + issues.close(issue1.id, function (err, msg) { | |
79 … | + t.error(err, 'close') | |
80 … | + t.ok(msg, 'msg') | |
81 … | + awaitMsg(sbot, msg, function (err) { | |
82 … | + t.error(err, 'await') | |
83 … | + t.equals(issue1.open, false, 'closed') | |
84 … | + t.end() | |
85 … | + }) | |
86 … | + }) | |
87 … | +}) | |
88 … | + | |
89 … | +test('reopen the issue', function (t) { | |
90 … | + issues.reopen(issue1.id, function (err, msg) { | |
91 … | + t.error(err, 'reopen') | |
92 … | + t.ok(msg, 'msg') | |
93 … | + awaitMsg(sbot, msg, function (err) { | |
94 … | + t.error(err, 'await') | |
95 … | + t.equals(issue1.open, true, 'open') | |
96 … | + t.end() | |
97 … | + }) | |
98 … | + }) | |
99 … | +}) |
Built with git-ssb-web