Forgeplucker is a web-scraper. It pulls project data out of forge sites through the same HTML intercaces human beings use. Therefore, for each distinct forge type, it needs a handler class to tell it how to extract data from that forge. This document explains how to write a handler class.
Before coding, it would probably be a good idea for you to at least skim the blog entries that gave birth to this project. It may help you understand the design philosophy of the code better.
Pragmatics of Web-Scraping
Your handler class’s job is to extract project data. If you are lucky, your target forge already has an export feature that will dump everything to you in clean XML or JSON; in that case, you have a fairly trivial exercise using BeautifulStoneSoup or the Python-library JSON parser and can skip the rest of this section.
Usually, however, you’re going to need to extract the data from the same pages that humans use. This is a problem, because these pages are cluttered with all kinds of presentation-level markup, headers, footers, sidebars, and site-navigation gorp — any of which is highly likely to mutate any time the UI gets tweaked.
Here are the tactics we use to try to stay out of trouble:
-
When you don’t see what you expect, use the framework’s self.error() call to abort with a message. And put in lots of expect checks; it’s better for a handler to break loudly and soon than to return bad data. Fixing the handler to track a page mutation won’t usually be hard once you know you need to - and knowing you need to is why we have regression tests.
-
Use peephole analysis with regexps (as opposed to HTML parsing of the whole page) as much as possible. Every time you get away with matching on strictly local patterns, like special URLs, you avoid a dependency on larger areas of page structure which can mutate.
-
Throw away as many irrelevant parts of the page as you can before attempting either regexp matching or HTML parsing. (The most mutation-prone parts of pages are headers, footers, and sidebars; that’s where the decorative elements and navigation stuff tend to cluster.) If you can identify fixed end strings for headers or fixed start strings for footers, use those to trim (and error out if they’re not there); that way you’ll be safe even if the headers and footers mutate. This is what the narrow() method in the framework code is for.
-
Rely on forms. You can assume you’ll be logged in with authentication and permissions to modify project data, which means the forge will display forms for editing things like issue data and project-member permissions. Use the forms structure, as it is much less likely to be casually mutated than the page decorations.
-
When you must parse HTML, BeautifulSoup is available to handler classes. Use it, rather than hand-rolling a parser, unless you have to cope with markup so badly malformed that it cannot cope.
Handler Class Output
The handler class will have mathods for retrieving various portions of the project state. Some of these you will not need to define; they’re methods of the GenericForge class that will work when you provide the correct implementation methods in your derived class. Others you will need to write yourself.
Output Methods
Here are the methods every handler class must implement:
- pluck_trackers(timeless=False)
-
Fetch the contents of all trackers. If timeless is true, omit pull-interval timestamps (used for regression tests). If you use GenericForge, you don’t need to define this.
- pluck_artifact(tracker, issueid, vocabularies=None)
-
Fetch a particular tracker item. The tracker argument must be a tracker name this project defines (usually "bugs", "features", "tasks", or "support"). The issueid argument is a string or numeric bug ID. If vocabularies is not None, it will be treated as a dictionary and filled with tracker attribute vocabularies (see more about this in the discussion of ontology smoothing).
- pluck_permissions()
-
Get the project’s member-permissions table. The ways to fetch these are quite idiosyncratic by forge type, so framework code can’t help here: you’ll have to write your own method from scratch.
Each of these methods should return a JSON-conformable python dictionary object. What should be in that object is the tricky part. Different forges support different data models, so not all output format will be identicals. Here are some guidelines:
Output Guidelines
-
Try for 100% faithful data capture. Never throw away state, because you can never tell when a handler user might need it.
-
Make the dump look as much like an existing format as possible. Re-use class tags, structures and fieldnames from the sample dumps you’ll find in the regression check files. The Gna! regression check makes an especially good guide, as the data model of that forge is richer than most others.
-
Make it self-describing. Use "class" tags on objects to label the individual subparts with what they’re for. This will help client software, which should be able to use these to (among other things) not have to retain a lot of state about where it is when it’s crawling over the tree.
In order to support cross-forge statistical analyses, re-import, and other good things, these dumps will eventually have to be massaged into using a common data model. The project’s term for this is "ontology smoothing", and you need to be careful not to try to do too much of it in the handler class.
In general, the handler class should only transform the data in ways that are trivial, lossless, and make it look more like one of the pre-existing formats. Complicated and potentially lossy transformations should be left up to the code calling these handlers.
Here’s an example. On both Berlios and Savane, the member capability that allows tracker items to be assigned to a member is "Technician", abbreviated "T". But the capability required to edit tracker issue items is called different things: "Administrator" on Berlios and "Manager" on Savane. We prefer to use Savane terminology, reserving "Administrator" for someone with the capability to edit member permissions; so we smooth those capability names to "T" and "M" on both forges.
When you code ontology smoothing, explain it carefully in a comment with the words "ontology smoothing" actually in it. And keep it carefully separated from the actual extraction logic, in case someone needs to change it someday.
We have some more specific rules that mostly derive from these general guidelines, but I’ve shuffled those off to an appendix so they won’t clutter the exposition here.
Defining the Handler
Handler classes live in a multi-file module called forgeplucker; look in the directory by that name in the source distribution. A new handler named Foo should live in a file named forgeplucker/handle_foo.py. Other modules in that directory are support code: htmlscrape.py contains low-level functions for scraping HTML, and generic.py is the framework code for a generic forge.
Begin by declaring a new class for your forge type and adding it to the handlers variable in forgeplucker/init.py; you will also need a corresponding import statement. If it is associated with a well-known domain address, add that association to the site_to_handler dictionary.
Your handler should inherit from the GenericForge class, which will supply its logic skeleton and various default methods that often work for forge systems descended from SourceForge.
class FooForge(GenericForge): "Handler class for forge sites running the FooForge hosting suite." pass
The pass is a placeholder statement; as written, our class does not yet have real methods or members and is an exact clone of GenericForge.
Logging In
One of these methods FooForge will inherit is login_url(). The GenericForge version assumes that the site login form is at "http://%s/account/login.php", where %s gets the sitename substituted; this often works without modification. If the login page as at a different location on your forge system, you will have to write a login_url() method for you handler to override the one from GenericForge, but this is unusual.
The first method you are likely to actually have to write will be be the site login method, usually something like this:
class FooForge(GenericForge): "Handler class for forge sites running the FooForge hosting suite." def login(self, username, password): response = GenericForge.login(self, { 'form_loginname':username, 'form_pw':password, 'stay_in_ssl':1, 'login':'Login With SSL'}, "Personal Page")
You’ll need to look at the login page source to see what form elements it actually requires. You’ll also need to change "Personal Page" to a string that reliably occurs on the page you get on successful login, and does not occur on the page returned by an unsuccessful login. This is how your handler robot will tell whether it has entered the site properly and can proceed to fetch data. GenericForge will take care of handling authentication cookies for your session once your handler has logged in.
I find the Firefox LiveHeaders extension useful for debugging these methods. It’s easy to miss a required form element in the visual clutter of a login page’s source, but with LiveHeaders, you can snoop the transaction to see what a successful login through Firefox is actually sending.
Getting the Project ID
Projects on a forge system has a "Unix name" which is a legal Unix directory segment, conventionally an abbreviated name of the project in all-lowercase letters. This name is used to generate path names for files and CGI scripts associated with the project. On most modern forge systems, this Unix name identifies the project for all purposes.
On forge systems descended from older SourceForge versions, each project has a second internal project ID that is all numeric and not related to the Unix name in any obvious way. If that ID exists, it has to be used rather than the Unix name in generating various kinds of URL into the site.
If you find one of these older systems we don’t already support, you’ll have to write logic to get the numeric project ID from the Unix name, one, just after you log in. For an example of how this is done, see the method numid_from_name() in the Berlios handler.
In other methods you will need to use project Unix name or project numeric ID as appropriate. In the rest of this document, we’ll assume that your forge stem identifies projects in the modern style requiring Unix name only; adjust as needed.
Extracting Tracker State
The first thing you’ll want to do is write logic to extract the project’s issue-tracker state.
In order to write the code to do this, you need to know what GenericForge does to fetch tracker state. For each tracker, it first tries to fetch an index page listing artifact IDs. If there are more IDs than fit on the first index page, (usually this means more than 50) it fetches the next index page and repeats.
Once it has the entire list of artifact IDs, it then fetches the detail page for each ID on the page and parses out the artifact information on that.
The code for doing all these things lives in tracker classlets, little auxiliary classes visible only from within your forge handler. There will be one classlet for each tracker type the forge supports; a typical type set might include "bugs", "features", "support", "patches", and "tasks". You will probably also want to write a base Tracker classlet for the others to inherit from; on well-designed forges most of the logic will live there, with only trivial variations in the derived ones.
The most effective way to understand how tracker classlets work is to look at a working one.
class Tracker: "Generic tracker classlet for Savane." def __init__(self, parent): self.parent = parent self.optional = True self.chunksize = 50 self.zerostring = "No matching items found" self.artifactid_re = r'<a href="\?([0-9]+)">' # This may yield anchor markup around the submitter name. # Sometimes it's present, if the submitter is a known user self.submitter_re = r'Submitted by: </td>\s*<td[^>]*>(<a[^>]*>[^<]*</a>|[^<]+)</td>' self.date_re = r'Submitted on:[^<]*</td>\s*<td[^>]*>([^<]*)</td>' self.ignore = ('canned_response', 'comment_type_id', 'depends_search_only_artifact', 'depends_search_only_project', 'reassign_change_artifact', "add_cc", "cc_comment", "new_vote")
The tracker classlet needs a reference to the owning forge handler class. That’s what self.parent is.
On some forge systems, all projects always have all tracker types. On others, the set of trackers is configurable by project administrators. If the optional member is False, treat failure to fetch an index page for this tracker as a fatal error. If it’s true, simply skip this tracker when it’s missing.
The chunksize member is the expected number of bugs per index page. It’s used in computing URLs for index page fetches.
Some forges have a recognizable string that means a tracker index is empty. This is good to know, because if we have such a zerostring entry, and it’s not on the page, and we still count no artifacts, we can throw an error. If this ever happens it will mean the site has changed its HTML generation enough that the bug-plucker won’t work any more. If that happens, we want to fail noisily rather than returning a bogus empty dump.
The artifactid_re member is the regular expression we use to mine bug ID numbers out of an index page.
The submitter_re and date_re members are the regular expression we used to mine the artifact submitter and date out of an artifact detail page fetched by ID.
To get the rest of the data about an artifact from its detail page, GenericForge does various kinds of parsing of the HTML. One thing it looks for is the contents of form fields. The ignore member is a list of the names of form fields to ignore. In this example, all the names up to reassign_change_artifact belong to fields on auxiliary forms that control the forge interface rather than reflecting project data. The later ones are for user entry fields for new data; they’ll be empty in this case.
def access_denied(self, page, issue_id=None): return issue_id is not None and re.search("This task has [0-9]+ encouragements? so far.", page)
The method for telling whether we failed to get tracker technician access to the project. Without this, we don’t get the right form elements on detail pages to be able to parse out the data. This metod is called on index pages with issue_id=None and on detail pages with the error ID as argument; if it is ever True, the generic forge framework code will throw an access-denied error.
def has_next_page(self, page): m = re.search("([0-9]+) matching items - Items [0-9]+ to ([0-9]+)", page) if not m: self.parent.error("missing item count header") else: return int(m.group(1)) > int(m.group(2))
The method for checking whether or not there’s a next page. Observe that it throws an error if it doesn’t see what it’s expecting. In order not to get spurious data, it’s best when our web-scraping methods have lots and lots of sanity checks, and fail loudly when one doesn’t pass.
def chunkfetcher(self, offset): "Get a tracker index page - all artifact IDs, open and closed.." return "%s/index.php?go_report=Apply&group=%s&func=browse&set=custom&chunksz=50&offset=%d" % (self.type, self.parent.project_name, offset) def detailfetcher(self, artifactid): "Generate a detail URL for the specified artifact ID." return "%s/index.php?%d" % (self.type, artifactid)
Generate the URLs to get index pages and artifact detail pages. Note that the URLs generated are local; the GenericForge methods will add the sitename and service prefix.
def narrow(self, text): "Get the section of text containing editable elements." return self.parent.skipspan(text, "form", 1)
Now we get to the more difficult parts. First, the narrow() method gets the part of the webpage that is interesting for data-extraction purposes. This will be an HTML <FORM> element, often but not necessarily the second one in sequence. (On many systems there will be an earlier form containing a site search box, which is rendered as part of a sidebar.)
Once the right form has been extracted, there’s a lot of data that the GenericForge code can extract automatically just by looking at <SELECT> elements and text <INPUT> boxes.
The artifact’s Open/Closed status will do for an example. Because we logged in with tracker-technician credentials, the Open/Closed status is presented as a drop-down menu generated by a select. By looking for the <SELECT>, we avoid having to parse prsentation-level tag soup. As a side benefit, looking at the contained <OPTION> elements tells us not only the current value of the field, but all possible values.
The most important thing we get from text <INPUT> elements is the artifact summary line. If the project has custom metadata fields, for tems, this part of the parse will pick them up as well.
This is also when the submitter_re and submitter_date regexpa get applied to extract the submitter’s name. These aren’t in form elements because they can’t be modified after the artifact is created.
Most of the forge-type-specific work of parsing datra out of a detail page goes in a method called custom which has to be custom-written for each forge type. Here’s the one for Savane. It’s responsible for parsing artifact comments (that part is handed iff to a helper), votes, and modification history. Essentially, everything we can’t extract automatically be looking at <FORM> elements.
def custom(self, contents, artifact): artifact['comments'] = self.parent.parse_followups(contents) artifact["attachments"] = [] for m in re.finditer(r'<a href="([^"]*)">file #([0-9]+): ([^<]*)</a> added by <a href="[^"]*">([^<]*)</a> <span class="smaller">\((\w+) - ([^)]*)\)', contents): filename = m.group(1) fileid = m.group(2) filename = m.group(3) attacher = m.group(4) size = m.group(5) mimetype = m.group(6) artifact["attachments"].append({ "class":"ATTACHMENT", "filename": filename, "id": fileid, "attached_by": attacher, "size":size, "mimetype":mimetype, }) if ("No files currently attached" in contents) != (len(artifact["attachments"])==0): self.parent.error("garbled file-attachment section, possible version-skew problem.") artifact['votes'] = None if "There is 1 vote so far." in contents: artifact['votes'] = 0 else: m = re.search("There are ([0-9]*) votes so far.", contents) if m: artifact['votes'] = int(m.group(1)) if artifact['votes'] is None: raise('cannot find a vote indication') artifact["history"] = [] for (date, by, field, old, dummy, new) in self.parent.table_iter(contents, "History", 6, "history", has_header=True): # Savane leaves the Date and Changed-by fields blank # (actually, containing ) when they represent # the second or later field changes made at one time. if date[-1].isdigit(): date = self.parent.isodate(date) elif date == ' ': date = '' if by == ' ': by = '' artifact["history"].append({"class":"EVENT", 'field': field, 'old': old, 'new': new, 'date':date, 'by': by})
It’s very common for forge systems to structure sequences in output (such as sequences of comments, or modification histories) as tables A lot of the hard work in this implementation of custom gets done by helper functions that walk through tales with specific titles.
Now let’s look at the actual tracker classes. Savane has s relatively well-defined user interface; the trackers all behave the same way, so users can form consistent behavioral expectations.
class BugTracker(Tracker): def __init__(self, parent): Savane.Tracker.__init__(self, parent) self.type = "bugs" class PatchTracker(Tracker): def __init__(self, parent): Savane.Tracker.__init__(self, parent) self.type = "patch" class TaskTracker(Tracker): def __init__(self, parent): Savane.Tracker.__init__(self, parent) self.type = "task" class SupportTracker(Tracker): def __init__(self, parent): Savane.Tracker.__init__(self, parent) self.type = "support"
The type member will be used to name the tracker data in the output report.
Now we’ll look at the Savane handler class itself:
def __init__(self, host, project_name): GenericForge.__init__(self, host, project_name); self.host = host self.project_name = project_name self.verbosity = 0 self.trackers = [Savane.BugTracker(self), Savane.SupportTracker(self), Savane.TaskTracker(self), Savane.PatchTracker(self)]
The key thing here is instantiating the list of tracker objects. The GenericForge code will walk through this list looking for data from each of them.
Our next function canonicalizes dates into ISO 8601 form. Other code calls this through the GenericForge method isodate(), which will catch format mismatch errors and convert them to ForgePlucker errors we can dispatch on. The code should be pretty self-explanatory.
@staticmethod def canonicalize_date(date): "Canonicalize Savane dates to ISO." # SavaneCleanup dates will look like this: Fri 22 Jun 2001 # 09:54:07 AM UTC. The timezone is variable according to the # user's preferences; sometimes SavaneCleanup omits the # timezone. Beware that strptime(3) doesn't actually interpret # %Z, it only barfs on bad values. So we have to do it. # # Savane dates may look like this: "Sun Jul 27 11:08:45 2008" # (note the absence of timezone and that time and year are swapped) # or like this: "Thursday 10/15/2009 at 06:40". if date == '-': # Gna! sometimes generates these return 'None' if '/' in date: # Gna! date t = time.strptime(date, "%A %m/%d/%y at %H:%M") elif date[-1].isalpha(): # Savannah date with timezone t = time.strptime(date, "%a %d %b %Y %H:%M:%S %p %Z") else: # Savannah date without timezone t = time.strptime(date, "%a %b %d %H:%M:%S %Y") if "UTC" in date or "GMT" in date: secs = calendar.timegm(t) # struct time in UTC to secs since epoch else: secs = time.mktime(t) # struct local time to secs since epoch t = time.gmtime(secs) # secs since epoch to struct UTC time return time.strftime("%Y-%m-%dT%H:%M:%SZ", t)
Every forge handler needs a login method. The GenericForge method takes a dictionary of requirted login-form elements and a check string. If the form response fails to contain the chreck string, the login failed and the GenericForge code will throw an error.
def login(self, username, password): response = GenericForge.login(self, { 'uri':'/', 'form_loginname':username, 'form_pw':password, 'stay_in_ssl':1, 'brotherhood':1, 'login':'Login'}, "My Incoming Items")
Now the code for parsing comments on detail pages. Older SourceForge-based systems distinguish between the original submission and other comments, presenting them slightly differently. Savane doesn’t — the submission comment is just comment #0 in the sequence.
def parse_followups(self, contents): "Parse followups out of a displayed page in a bug or patch tracker." comments = [] # Look for the anchor Savane creates for the Discussion section. begin = contents.find('href="#discussion"') if begin == -1: self.notify("no discussion") else: contents = contents[begin:].replace("<br />", "<br/>") # There's no decorative header element in this table. for row in walk_table(contents): try: # Savane comments have *two* column elents per comment, # one for date and text, the second for the user. (comment, submitter) = row date = comment.split("<br/>")[0] comment = "\n".join(comment.split("<br/>")[1:]) # Submitter's name may have HTML markup in it. # But first toss everything after <br/>, it's image # cruft. submitter = submitter.split("<br/>")[0] submitter = dehtmlize(submitter) # Throw out everything past the terminating comma # If this leads to an ill-formed date, because somebody # moved the punctuation, isodate() should throw an error m = re.search("<[^>]+>([^<]+),", date) date = self.isodate(m.group(1)) # Comment may have HTML in it. Do normal markup # stripping,but first deal with the funky way that # Savane emits paragraph delimiters. comment = comment.replace("\n\n", "\n") comment = comment.replace("\n</p>\n<p>", "\n") comment = blocktext(dehtmlize(comment)) # Stash the result. comments.append({"class":"COMMENT", 'submitter':submitter, 'date':date, 'comment':comment}) except ValueError: raise ForgePluckerException("mangled followup,") return comments
The code for fetching the member permissions table. Not really complicated, there’s just a lot of it. Much of it is sanity checks intended to detect if permissions pages ever change format.
def pluck_permissions(self): "Retrieve the developer roles table." expected_features = [u'Support Tracker', u'Bug Tracker', u'Task Tracker', u'Patch Tracker', u'Cookbook Manager', u'News Manager'] # This maps some features to standard names. feature_map = ('support', 'bugs', 'tasks', 'patch', 'Cookbook', 'News') permission_map = {u'Group Default': None, u'None': [], u'Technician': ['T'], u'Manager':['M'], u'Techn. & Manager':['T', 'M']} page = self.fetch("project/admin/userperms.php?group=%s" % self.project_name, "Permissions Table") if not "Update Permissions" in page: self.error("you need admin privileges to extract permissions", ood=False) # Ignore all the site navigation crap, it's likely to mutate goodstuff = page.find('<form action="/project/admin/userperms.php" method="post">') if goodstuff == -1: self.error("permissions form not found where expected") else: page = page[goodstuff:] trailing = page.find("</form>") if trailing == -1: self.error("expected trailing </form> element not found") else: page = page[:trailing+7] # Actual parsing begins here form = BeautifulSoup(page) # Extract feature headings defaults = form("table")[1] features = map(lambda x: x.contents[0], defaults.tr.findAll("th")) if features != expected_features: self.error("feature set %s is not as expected" % features) # Extract group default permissions defvals = map(lambda x: x.contents[3].strip(), defaults.findAll("td")) for d in defvals: if not d.startswith("(") or not d.endswith(")"): self.error("default group values were not as expected") defvals = map(lambda x: x[1:-1].strip(), defvals) dfltmap = dict(zip(features, defvals)) # Extract actual permissions. The [1:] discards the header row capabilities = {} permissions = form("table")[2].findAll("tr")[1:] for row in permissions: fields = map(lambda x: x.contents, row.findAll("td")) namefield = fields[0] baseperms = fields[1] try: trackerperms = map(lambda x: select_parse(x[1])[0], fields[2:]) except: self.error("tracker permissions were not as expected") try: name = namefield[1].contents[0] except: self.error("name field in permissions was not as expected") person = dehtmlize(name) capabilities[person] = {} admin = trusted = False if "You are Admin" in baseperms[0]: admin = True else: admin_input = baseperms[1] if not 'admin' in `admin_input`: self.error("admin checkbox not found where expected") else: admin = 'checked' in `admin_input` if admin: trusted = True for field in baseperms: if 'input' in `field` and 'privacy' in `field` and "checked" in `field`: trusted = True capabilities[person]["Admin"] = admin capabilities[person]["Trusted"] = trusted for (feature, value, default) in zip(feature_map, trackerperms, defvals): if permission_map[value] is None: value = permission_map[default] capabilities[person][feature] = value return capabilities
Testing Your Handler Class
To verify your handler class, you should either use an existing project on the target forge of which you are already a member, or create a new test project. Some random project won’t do, as you will need tracker administrator access to extract bug state. It’s best if you can create a dedicated forgeplucker-test project in which you can manipulate and inspect the data as you like.
We presently have forgeplucker-test projects at the following places:
We’ll join you to all of these with the required access for testing. Don’t forget to add your site credentials to .netrc where our code will see them; see the forgeplucker manual page for details.
There is a test subdirectory containing regression check files, and a regression-test harness in the main directory that knows how to compare state pulls from our dummy projects with those check files. You can use the script to test all handlers, rebuild an individial test after changing a dummy project’s content, or to rebuild all tests.
Run "regress-driver.py --help" to learn its control options.
Appendix: Rules for JSON Output
-
Use ISO-8601 format for dates and timestamps.
-
Use the "class" attribute in JSON objects where appropriate to indicate the semantic kind you are dumping. Defined values are:
-
ARTIFACT: for a tracker issue
-
ATTACHMENT: a file attached to an artifact
-
COMMENT: for a comment attached to a tracker issue
-
IDENTITY: for the identity of a project member
-
PROJECT: for a project state
-
-
The top level object (called PROJECT) of your dump should always include the following metadata tags:
-
forgetype: The name of the handler class it was generated by
-
format_version: The current major version of the output format
-
host: The FQDN of the site from which it was dumped
-
project: The name of the project
-
artifacts: list of ARTIFACT objects.
-
vocabularies: a dictionary mapping names of controlled-vocabulary fields to lists of the values they can have.
-
-
Standard fields of an ARTIFACT:
-
class: "ARTIFACT"
-
assigned_to: None or the nick/IDENTITY of the technician it’s assigned to
-
attachments: list of attached attachments
-
comments: list of attached comments
-
dependents: list iof bug IDs that depend on this bug
-
history: a list of FIELDCHANGE objects
-
id: the ID of the artifact
-
summary: one-line summary of issue
-
type: preferably from controlled vocabulary:
-
"bugs"
-
"features"
-
"patches"
-
"support"
-
"tasks"
-
-
-
Standard fields of a COMMENT:
-
class: "COMMENT"
-
comment: the comment text
-
date: date of comment in ISO format
-
submitter: submitter name or IDENTITY
-
-
Standard fields of an ATTACHMENT:
-
class: "ATTACHMENT"
-
by: submitter name or IDENTITY
-
date: date of attachment in ISO format
-
description: one-line text description of the attachment
-
filename: filename of the attached file
-
id: ID of the attachment (used for reference in comments)
-
mimetype: MIME type of file
-
-
Standard fields of a FIELDCHANGE:
-
class: "FIELDCHANGE"
-
by: nick or IDENTITY of person who made the change
-
date: date of changr in ISO format
-
field: name of field changed
-
new: new value
-
old: old value
-
-
Standard fields of an IDENTITY:
-
class: "PROJECT"
-
name: Human name of a person
-
nick: A person’s ID on this forge
-
-
Standard capablities of a person:
-
"Admin": can edit membership tables
-
"Trusted": can look at private items
-
"Release Tech": can make releases
-
"T": (with respect to a tracker) can be assigned bugs from it
-
"M": (with respect to a tracker) can modify bugs on it
-