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