Tasks are attached to milestones, not series

Make tasks related to milestones, and associate milestones to the
actual *branches*. That's an elegant way to solve our need to track
"backport to milestone-proposed" tasks, solving an old issue we had
in Launchpad (and for which we were abusing "Fixreleased").

Each milestone is associated to a branch. There are three types of
branches: master (the future), release (the milestone-proposed branch,
when it's around), and stable/*. Milestones are mandatory, there is a
default 'undefined' milestone for when we don't really know when that
work will hit in the master branch.

In normal times we'd have the following unreleased milestones:
master branch -> undefined, havana-1, havana-2, havana-3
stable/grizzly branch -> 2013.1.3

When the milestone-proposed branch is created just before havana-1, we
move the havana-1 milestone from master to release branch:
master branch -> undefined, havana-2, havana-3
release branch -> havana-1
stable/grizzly branch -> 2013.1.3

That lets us track release-backporting tasks (targeted to
release/havana-1) separately from normal work (targeted to master/*).

At the final release, the release branch is just renamed to stable/* and
a new release branch is created for the new cycle.

Change-Id: Ia212ae9c40549fe484362cbd5b3f323a467edb76
This commit is contained in:
Thierry Carrez 2013-07-18 14:18:22 +02:00
parent ff8c4354ff
commit bbb154e23f
11 changed files with 78 additions and 99 deletions

View File

@ -17,7 +17,7 @@ Current features
*Bug tracking*
Like Launchpad Bugs, StoryBoard implements bugs as stories, with tasks that
may affect various project/series combinations. You can currently create
may affect various project/branch combinations. You can currently create
bugs, tasks for bugs, edit their status, comment on them, etc. The current
POC is incomplete: it does not do any sort of form client-side validation,
and is missing search features, pagination, results ordering. This should
@ -76,38 +76,13 @@ Future features
a 1:1 relationship between tasks and merges (one merge = one task marked
'Landed')
*Series tracking*
A new tab for StoryBoard, giving you per-series and per-milestone views of
progress. Would replace the need for status.o.o/releasestatus. Series and
*Development cycle tracking*
A new tab for StoryBoard, giving you per-cycle and per-milestone views of
progress. Would replace the need for status.o.o/releasestatus. Cycles and
milestones could be specified per project, although having a default, common
set would avoid duplication (and allow cross-project milestone views).
*Story dependencies*
Some stories relate to each other (duplicates, related, depend on...) and we
should be able to access those easily.
*Official tags*
Currently all tags are considered custom (grey color). A set of official tags
should be created (with associated colors and autocomplete magic) for easier
reuse of popular tags.
*Privileged actions*
Currently everyone can do everything. Features prioritization, for example,
should probably be restricted to PTL/drivers-style group.
*Embargoed vulnerabilities support*
Support for private stories that can be accessed only by a per-story set of
users.
*Patches in comments*
Have the ability to attach patches to comments.
*Email notifications*
We'd certainly need email notifications of some kind, too.
*Admin actions*
Currently creating series/milestones/projects is done through the default
Django admin app. StoryBoard could use something a bit more friendly.
See https://github.com/ttx/storyboard/issues for more feature backlog.
Install, test and run
@ -142,8 +117,8 @@ Basic test using Django development server
Run Django development server:
./manage.py runserver
Create basic data (at least a series, a milestone, a project) through the
admin server (using the admin credentials above) at:
Create basic data (at least a master and release branch, a milestone, a
project) through the admin server (using the admin credentials above) at:
http://127.0.0.1:8000/admin/
Then log out and access the application at:

View File

@ -3,12 +3,12 @@
<div class="hero-unit">
<h1>StoryBoard</h1>
<h3>A task tracking system for inter-related projects.</h3>
<p>StoryBoard lets you track what needs to be done across projects and series. It is a proof-of-concept demo of what the ideal OpenStack task tracker would look like. It may or may not end up replacing Launchpad Bugs/Blueprints for OpenStack task tracking and release management.</p>
<p>StoryBoard lets you track what needs to be done across projects and branches. It is a proof-of-concept demo of what the ideal OpenStack task tracker would look like. It may or may not end up replacing Launchpad Bugs/Blueprints for OpenStack task tracking and release management.</p>
</div>
<div class="row-fluid">
<div class="span4">
<h2>Stories</h2>
<p>It all begins with a <strong>story</strong>. A story is a bug report or proposed feature. Stories are then further split into <strong>tasks</strong>, which affect a given project and series. You can easily track backports of bugs to a specific series, or plan cross-project features.</p>
<p>It all begins with a <strong>story</strong>. A story is a bug report or proposed feature. Stories are then further split into <strong>tasks</strong>, which affect a given project and branch. You can easily track backports of bugs to a specific branch, or plan cross-project features.</p>
<p><a class="btn" href="/story">Access stories &raquo;</a></p>
</div><!--/span-->
<div class="span4">

View File

@ -15,11 +15,11 @@
from django.contrib import admin
from storyboard.projects.models import Branch
from storyboard.projects.models import Milestone
from storyboard.projects.models import Project
from storyboard.projects.models import Series
admin.site.register(Branch)
admin.site.register(Project)
admin.site.register(Series)
admin.site.register(Milestone)

View File

@ -24,14 +24,15 @@ class Project(models.Model):
return self.name
class Series(models.Model):
SERIES_STATUS = (
(0, 'Old'),
(1, 'Supported'),
(2, 'Active'),
(3, 'Future'))
name = models.CharField(max_length=50, primary_key=True)
status = models.IntegerField(choices=SERIES_STATUS)
class Branch(models.Model):
BRANCH_STATUS = (
('M', 'master'),
('R', 'release'),
('S', 'stable'),
('U', 'unsupported'))
name = models.CharField(max_length=50)
short_name = models.CharField(max_length=20)
status = models.CharField(max_length=1, choices=BRANCH_STATUS)
release_date = models.DateTimeField()
def __unicode__(self):
@ -43,8 +44,9 @@ class Series(models.Model):
class Milestone(models.Model):
name = models.CharField(max_length=50)
series = models.ForeignKey(Series)
active = models.BooleanField(default=True)
branch = models.ForeignKey(Branch)
released = models.BooleanField(default=False)
undefined = models.BooleanField(default=False)
def __unicode__(self):
return self.name

View File

@ -11,7 +11,7 @@
<th>Story</th>
<th>Priority</th>
<th>Task</th>
<th>Series</th>
<th>Branch</th>
<th>Assignee</th>
<th>Milestone</th>
</tr>
@ -24,9 +24,9 @@
<td><span class="badge{{ task.story.priority|priobadge }}">
{{ task.story.get_priority_display }}</span></td>
<td>{{ task.title }}</td>
<td>{{ task.series.name }}</td>
<td>{{ task.milestone.branch.name }}</td>
<td>{{ task.assignee.username }}</td>
<td>{{ task.milestone.name }}</td>
<td>{% if not task.milestone.undefined %}{{ task.milestone.name }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>

View File

@ -18,7 +18,6 @@ from django.db import models
from storyboard.projects.models import Milestone
from storyboard.projects.models import Project
from storyboard.projects.models import Series
class Story(models.Model):
@ -52,14 +51,13 @@ class Task(models.Model):
story = models.ForeignKey(Story)
title = models.CharField(max_length=100, blank=True)
project = models.ForeignKey(Project)
series = models.ForeignKey(Series)
assignee = models.ForeignKey(User, blank=True, null=True)
status = models.CharField(max_length=1, choices=TASK_STATUSES, default='T')
milestone = models.ForeignKey(Milestone, blank=True, null=True)
milestone = models.ForeignKey(Milestone)
def __unicode__(self):
return "%s %s/%s" % (
self.story.id, self.project.name, self.series.name)
self.story.id, self.project.name, self.branch.short_name)
class Comment(models.Model):

View File

@ -14,23 +14,29 @@
<input class="input-block-level" name="project" id="prependedInput"
type="text" value="">
</div>
<label>Series</label>
<div class="btn-group" data-toggle="buttons-radio">
{% for series in active_series %}
{% if series.status == 2 %}
<button type="button" data-value="{{ series.name }}"
class="addtask_series btn btn-small active">Current ({{series.name}})</button>
<label>Branch / Milestone</label>
{% regroup milestones by branch as branch_list %}
<div class="btn-toolbar" data-toggle="buttons-radio">
{% for branch in branch_list %}
<div class="btn-group">
<button type="button" data-value="{{ milestone.id }}"
class="btn btn-small disabled"><b>{{branch.grouper.name}}</b></button>
{% for milestone in branch.list %}
{% if milestone.branch.status == 'M' and milestone.undefined %}
<button type="button" data-value="{{ milestone.id }}"
class="addtask_milestone btn btn-small active">{{milestone.name}}</button>
{% else %}
<button type="button" data-value="{{ series.name }}"
class="addtask_series btn btn-small
{% if series.status == 1 %}btn-success{% endif %}">{{ series.name }}</button>
<button type="button" data-value="{{ milestone.id }}"
class="addtask_milestone btn btn-small">{{ milestone.name }}</button>
{% endif %}
{% endfor %}
</div>
<label class="after-buttongroup">Comment</label>
{% endfor %}
</div>
<label>Comment</label>
<textarea class="input-block-level" rows="6" name="comment"
placeholder="Add a comment"></textarea>
<input type="hidden" id="addtask_series" name="series" value="">
<input type="hidden" id="addtask_milestone" name="milestone" value="">
</div>
<div class="modal-footer">
<button class="btn" data-dismiss="modal" aria-hidden="true">Close</button>
@ -39,7 +45,7 @@
</div>
</form>
<script type="text/javascript">
$(".addtask_series").click(function() {
$("#addtask_series").val($(this).data("value"));
$(".addtask_milestone").click(function() {
$("#addtask_milestone").val($(this).data("value"));
});
</script>

View File

@ -4,7 +4,7 @@
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
<h3 id="deltaskLabel">Delete
{{task.project.name}}/{{task.series.name}} task ?</h3>
{{task.project.name}}/{{task.branch.shortname}} task ?</h3>
</div>
<div class="modal-body">
<label>Comment</label>

View File

@ -5,7 +5,7 @@
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
<h3 id="edittaskLabel">Edit
{{task.project.name}}/{{task.series.name}} task</h3>
{{task.project.name}}/{{task.milestone.branch.short_name}} task</h3>
</div>
<div class="modal-body">
<label>Title <small>(optional)</small></label>
@ -18,20 +18,21 @@
</div>
<label>Milestone</label>
<div class="btn-group" data-toggle="buttons-radio">
<button type="button" data-value=""
class="btn btn-small edittask_ms{{task.id}}
{% if not task.milestone %}active{% endif %}">
None</button>
{% if task.milestone not in milestones %}
<button type="button" data-value="{{task.milestone.id}}"
class="btn btn-small active edittask_ms{{task.id}} btn-success">
{{ task.milestone.name }}</button>
{% endif %}
{% for milestone in milestones %}
{% if milestone.series == task.series %}}
{% if milestone.branch == task.milestone.branch %}}
<button type="button" data-value="{{milestone.id}}"
class="btn btn-small
{% if task.milestone == milestone %}active{% endif %}
{% if milestone.active or task.milestone == milestone %}edittask_ms{{task.id}}{%endif%}
{% if not milestone.active %}btn-success
{% if not milestone.released or task.milestone == milestone %}edittask_ms{{task.id}}{%endif%}
{% if milestone.released %}btn-success
{% if task.milestone != milestone %}disabled{%endif%}
{% endif %}"
{% if task.milestone != milestone and not milestone.active %}disabled="disabled"{%endif%}>
{% if task.milestone != milestone and milestone.released %}disabled="disabled"{%endif%}>
{{ milestone.name }}</button>
{% endif %}
{% endfor %}

View File

@ -33,7 +33,7 @@
<tr>
<th>Task</th>
<th>Project</th>
<th>Series</th>
<th>Branch</th>
<th>Assignee</th>
<th>Status</th>
<th>Milestone</th>
@ -44,10 +44,10 @@
<tr class="{{ task.status|taskcolor }}">
<td>{{ task.title }}</td>
<td>{{ task.project.title }}</td>
<td>{{ task.series.name }}</td>
<td>{{ task.milestone.branch.name }}</td>
<td>{{ task.assignee.username }}</td>
<td>{{ task.get_status_display }}</td>
<td>{{ task.milestone.name }}</td>
<td>{% if not task.milestone.undefined %}{{ task.milestone.name }}{% endif %}</td>
<td>
<a href="#edittask{{ task.id }}" class="btn btn-micro" data-toggle="modal"><i class="icon-edit"></i></a>
<a href="#deltask{{ task.id }}" class="btn btn-micro" data-toggle="modal"><i class="icon-remove"></i></a>

View File

@ -21,7 +21,6 @@ from django.views.decorators.http import require_POST
from storyboard.projects.models import Milestone
from storyboard.projects.models import Project
from storyboard.projects.models import Series
from storyboard.stories.models import Comment
from storyboard.stories.models import Story
from storyboard.stories.models import StoryTag
@ -37,14 +36,13 @@ def dashboard(request):
def view(request, storyid):
story = Story.objects.get(id=storyid)
active_series = Series.objects.filter(status__gt=0)
milestones = Milestone.objects.all()
milestones = Milestone.objects.filter(
released=False).order_by('branch__release_date')
return render(request, "stories.view.html", {
'story': story,
'milestones': milestones,
'priorities': Story.STORY_PRIORITIES,
'taskstatuses': Task.TASK_STATUSES,
'active_series': active_series,
})
@ -96,13 +94,14 @@ def add_story(request):
newstory.save()
proposed_projects = request.POST['projects'].split()
if proposed_projects:
series = Series.objects.get(status=2)
master_undefined_milestone = Milestone.objects.get(
branch__status='M', undefined=True)
tasks = []
for project in proposed_projects:
tasks.append(Task(
story=newstory,
project=Project.objects.get(name=project),
series=series,
milestone=master_undefined_milestone,
))
Task.objects.bulk_create(tasks)
proposed_tags = set(request.POST['tags'].split())
@ -129,19 +128,20 @@ def add_task(request, storyid):
story = Story.objects.get(id=storyid)
try:
if request.POST['project']:
if request.POST['series']:
series = Series.objects.get(name=request.POST['series'])
if request.POST['milestone']:
milestone = Milestone.objects.get(id=request.POST['milestone'])
else:
series = Series.objects.get(status=2)
milestone = Milestone.objects.get(branch__status='M',
undefined=True)
newtask = Task(
story=story,
title=request.POST['title'],
project=Project.objects.get(name=request.POST['project']),
series=series,
milestone=milestone,
)
newtask.save()
msg = "Added %s/%s task " % (
newtask.project.name, newtask.series.name)
newtask.project.name, newtask.milestone.branch.short_name)
newcomment = Comment(story=story,
action=msg,
author=request.user,
@ -162,13 +162,8 @@ def edit_task(request, taskid):
if (task.title != request.POST['title']):
actions.append("title")
task.title = request.POST['title']
if not request.POST['milestone']:
milestone = None
milestonename = "None"
else:
milestone = Milestone.objects.get(
id=int(request.POST['milestone']))
milestonename = milestone.name
milestone = Milestone.objects.get(id=int(request.POST['milestone']))
milestonename = milestone.name
if (milestone != task.milestone):
actions.append("milestone -> %s" % milestonename)
task.milestone = milestone
@ -186,7 +181,8 @@ def edit_task(request, taskid):
actions.append("assignee -> %s" % assigneename)
task.assignee = assignee
if actions:
msg = "Updated %s/%s task " % (task.project.name, task.series.name)
msg = "Updated %s/%s task " % (task.project.name,
task.milestone.branch.short_name)
msg += ", ".join(actions)
task.save()
newcomment = Comment(story=task.story,
@ -205,7 +201,8 @@ def edit_task(request, taskid):
def delete_task(request, taskid):
task = Task.objects.get(id=taskid)
task.delete()
msg = "Deleted %s/%s task" % (task.project.name, task.series.name)
msg = "Deleted %s/%s task" % (task.project.name,
task.milestone.branch.short_name)
newcomment = Comment(story=task.story,
action=msg,
author=request.user,